diff --git a/.copilot/skills/pr-lifecycle/SKILL.md b/.copilot/skills/pr-lifecycle/SKILL.md new file mode 100644 index 000000000..50456644e --- /dev/null +++ b/.copilot/skills/pr-lifecycle/SKILL.md @@ -0,0 +1,537 @@ +--- +name: "pr-lifecycle" +description: "Complete issue → PR → merge lifecycle with readiness checks" +domain: "workflow" +confidence: "high" +source: "extracted from CONTRIBUTING.md, PR_REQUIREMENTS.md, squad-ci.yml" +--- + +## Context + +This skill is the **canonical Copilot-agent lifecycle for the Squad repository**. It covers the full path from picking up a GitHub issue to merging a PR. Where older docs (templates, copilot-instructions, CONTRIBUTING.md) conflict with this skill, **this skill takes precedence** for Copilot agents. + +For advanced scenarios (worktrees, multi-repo coordination), see `.copilot/skills/git-workflow/SKILL.md`. + +## Scope + +✅ **THIS SKILL COVERS:** +- Issue pickup and branch creation +- Implementation, build, test, lint workflow +- Pre-push safety verification +- PR creation with correct target, title, body, and labels +- All 11 PR readiness checks (what they check, how to pass, how to fix) +- Post-creation maintenance (review feedback, rebasing, CI) +- Merge preconditions and cleanup + +❌ **THIS SKILL DOES NOT COVER:** +- Worktree-based parallel work (see `git-workflow` skill) +- Multi-repo coordinated PRs (see `git-workflow` skill) +- Release process / publishing (see `.squad/skills/release-process`) +- Reviewer lockout protocol (see `.copilot/skills/reviewer-protocol`) +- Architectural or security review checklists + +--- + +## Lifecycle Procedure + +### Phase 1 — Issue Pickup + +1. **Read the issue.** Understand the acceptance criteria before writing code. + +2. **Confirm capability fit.** Check your capability profile in `.squad/team.md`. 🟢 = proceed. 🟡 = proceed but flag in PR. 🔴 = comment on issue and stop. + +3. **Branch from dev:** + ```bash + git fetch origin dev + git checkout dev + git rebase origin/dev + git checkout -b squad/{issue-number}-{slug} + ``` + - Branch name format: `squad/{issue-number}-{kebab-case-slug}` + - Example: `squad/42-fix-login-validation` + - **Never** branch from `main` + +4. **Mark in-progress** (optional): + ```bash + gh issue edit {number} --add-label "status:in-progress" + ``` + +--- + +### Phase 2 — Implementation + +1. **Make your changes.** Follow the codebase conventions: + - TypeScript strict mode, no `@ts-ignore` + - ESM-only, async/await + - JSDoc on new public APIs + +2. **Build:** + ```bash + npm run build + ``` + +3. **Test:** + ```bash + npm test + ``` + +4. **Type check:** + ```bash + npm run lint + ``` + +5. **Stage specific files only:** + ```bash + git add path/to/file1.ts path/to/file2.ts + ``` + - ❌ **NEVER** `git add .`, `git add -A`, or `git commit -a` + - ✅ **ALWAYS** name each file explicitly + +6. **Pre-push safety check:** + ```bash + # Verify file count matches intent (expect ≤10 files for most fixes) + git diff --cached --stat + + # Verify NO unintended deletions + git diff --cached --diff-filter=D --name-only + ``` + If you see unexpected files or deletions, unstage them: `git reset HEAD ` + +7. **Single commit with issue reference:** + ```bash + git commit -m "Brief description of change + + Closes #{issue-number} + + Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" + ``` + - If you already have multiple commits, squash: `git rebase -i origin/dev` and squash all into one + - **Never** use `git reset --soft` to squash — it picks up delta from dev and contaminates the commit + +8. **Add changeset (if required):** + A changeset is required when your PR modifies files under `packages/squad-sdk/src/` or `packages/squad-cli/src/`. + + ```bash + npx changeset add + ``` + This prompts for: which packages changed, bump type (patch/minor/major), summary. + + Or create manually at `.changeset/{descriptive-name}.md`: + ```markdown + --- + '@bradygaster/squad-cli': patch + --- + + Brief description of the change + ``` + + If the changeset creates a second commit, squash it into your main commit. + +--- + +### Phase 3 — PR Creation + +1. **Push:** + ```bash + git push -u origin squad/{issue-number}-{slug} + ``` + +2. **Create PR targeting dev:** + ```bash + gh pr create --repo bradygaster/squad --base dev \ + --title "fix: brief description (#issue-number)" \ + --body "Closes #{issue-number} + + ## Summary + What this PR does and why. + + ## Changes + - File-level description of changes + + Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" + ``` + +3. **Title conventions:** + - Bug fix: `fix: description (#N)` + - Feature: `feat: description (#N)` + - Docs: `docs: description (#N)` + - Chore/infra: `chore: description (#N)` + +4. **Labels:** + - `fix`, `feat`, `docs`, or `repo-health` for type + - `squad:{agent-name}` if working as a squad member + - `skip-changelog` only with reviewer approval (escape hatch) + +5. **Scope rules by label:** + - `repo-health`: Only modify `.github/`, `scripts/`, root config, tests, docs. **Never** modify `packages/*/src/` + - `fix` or `feat`: May modify product source. Must include changeset when touching `packages/*/src/` + +--- + +### Phase 4 — PR Readiness + +An automated readiness check runs on every push and posts a checklist comment on the PR. All 11 checks must pass before review (check 11 is informational-only). + +#### Check 1: Single Commit + +| | | +|---|---| +| **What** | PR must contain exactly 1 commit | +| **Pass** | Push a single, squashed commit | +| **Fix** | `git rebase -i origin/dev` → squash all commits into one → `git push --force-with-lease` | +| **Gotcha** | Never `git reset --soft` to squash — contaminates the commit with unrelated changes | + +#### Check 2: Not in Draft + +| | | +|---|---| +| **What** | PR must not be marked as draft | +| **Pass** | Create PR as ready, or convert: `gh pr ready` | +| **Fix** | `gh pr ready {number}` or use the GitHub UI | +| **Gotcha** | The readiness check still runs on drafts but will show ❌ until you mark ready | + +#### Check 3: Branch Up to Date + +| | | +|---|---| +| **What** | PR branch must not be behind `dev` | +| **Pass** | Rebase onto latest dev before pushing | +| **Fix** | `git fetch origin dev && git rebase origin/dev && git push --force-with-lease` | +| **Gotcha** | After rebasing, the readiness check re-runs automatically on the new push | + +#### Check 4: Copilot Review + +| | | +|---|---| +| **What** | The `copilot-pull-request-reviewer` bot must post an `APPROVED` review | +| **Pass** | Wait — Copilot review is triggered automatically on PR creation/push | +| **Fix** | If Copilot hasn't reviewed after 5 minutes, push an empty commit to re-trigger: `git commit --allow-empty -m "trigger review" && git push` then squash before merge | +| **Gotcha** | Copilot review state is `APPROVED`, `CHANGES_REQUESTED`, or `COMMENTED`. Only `APPROVED` passes | + +#### Check 5: Changeset Present + +| | | +|---|---| +| **What** | PRs that modify `packages/squad-sdk/src/` or `packages/squad-cli/src/` must include a `.changeset/*.md` file or a `CHANGELOG.md` edit | +| **Pass** | Run `npx changeset add` and commit the generated file | +| **Fix** | `npx changeset add` → select affected package(s) → select bump type → write summary → `git add .changeset/ && git commit --amend --no-edit && git push --force-with-lease` | +| **Gotcha** | The `skip-changelog` label bypasses this check but requires reviewer approval. Non-source changes (docs, config, tests) don't need a changeset | + +#### Check 6: No Merge Conflicts + +| | | +|---|---| +| **What** | PR must be cleanly mergeable with the base branch | +| **Pass** | Keep branch rebased on dev | +| **Fix** | `git fetch origin dev && git rebase origin/dev` → resolve conflicts → `git push --force-with-lease` | +| **Gotcha** | GitHub may show `null` mergeability briefly while computing — the check treats this as passing | + +#### Check 7: Scope Clean + +| | | +|---|---| +| **What** | Warns if PR includes `.squad/` or `docs/proposals/` files | +| **Pass** | Don't include team state or proposal files in product PRs | +| **Fix** | Remove unintended files: `git reset HEAD .squad/ docs/proposals/` then amend your commit | +| **Gotcha** | This check is **informational only** — it always passes but flags attention. Including these files is OK if intentional (e.g., updating agent history) | + +#### Check 8: Copilot Threads Resolved + +| | | +|---|---| +| **What** | All review threads opened by `copilot-pull-request-reviewer` must be resolved | +| **Pass** | Address each Copilot comment, then click "Resolve conversation" in the GitHub UI | +| **Fix** | Go to the PR's "Files changed" tab → find unresolved Copilot threads → fix the code or explain why no change is needed → click "Resolve conversation" | +| **Gotcha** | Outdated threads (on code that's since changed) are automatically skipped. Only active, unresolved threads block | + +#### Check 9: CI Passing + +| | | +|---|---| +| **What** | All CI check runs (excluding the readiness check itself) must be green | +| **Pass** | Fix any build, test, or lint failures and push | +| **Fix** | Read the failing check's logs (`gh run view {run-id} --log-failed`), fix the issue, push | +| **Gotcha** | Pending/in-progress checks also show ❌. Wait for all checks to complete before evaluating. The readiness check re-runs after CI completes via `workflow_run` trigger | + +#### Check 10: Issue Linked + +| | | +|---|---| +| **What** | PR body or commit message must reference an issue (`Closes #N`, `Fixes #N`, `Resolves #N`, or `Part of #N`) | +| **Pass** | Include `Closes #N` in PR body or commit message | +| **Fix** | Edit PR body to add `Closes #{issue-number}`, or amend commit message | +| **Gotcha** | Case-insensitive matching. Only closing keywords are recognized | + +#### Check 11: Protected Files (informational) + +| | | +|---|---| +| **What** | Warns when zero-dependency bootstrap files are modified (always passes) | +| **Pass** | Always passes — this is informational only | +| **Fix** | If flagged, verify the changed bootstrap file still has zero external dependencies | +| **Gotcha** | Protected files are listed in `copilot-instructions.md`. The check warns but does not block | + +--- + +### Phase 5 — Post-Creation Maintenance + +1. **Handling Copilot review feedback:** + - Read each comment carefully + - Fix valid issues in your code + - Resolve each thread after addressing it + - Amend your single commit: `git commit --amend --no-edit && git push --force-with-lease` + +2. **Rebasing when behind dev:** + ```bash + git fetch origin dev + git rebase origin/dev + # Resolve any conflicts + git push --force-with-lease + ``` + +3. **Re-running CI:** + - CI re-runs automatically on every push + - To re-run without changes: `gh run rerun {run-id} --failed` + +4. **If readiness check is stale:** + - The readiness check re-runs on push and after CI completes + - If it's stale, push a trivial fix or empty commit to re-trigger + +--- + +### Phase 6 — Merge & Cleanup + +1. **Merge preconditions:** + - All 11 readiness checks pass (✅ across the board) + - Human reviewer has approved (or maintainer merges directly) + - No open blocking conversations + +2. **Who merges:** + - Agents do **not** merge PRs themselves + - Maintainers (bradygaster or designated reviewers) merge via GitHub UI + - The repo uses squash merge, so commit history is clean regardless + +3. **Post-merge cleanup:** + ```bash + git checkout dev + git pull origin dev + git branch -d squad/{issue-number}-{slug} + git push origin --delete squad/{issue-number}-{slug} + ``` + +4. **Verify issue auto-close:** + - If PR body contains `Closes #{N}`, the issue closes automatically on merge + - If not, manually close: `gh issue close {number}` + +--- + +## Examples + +### Example: Bug fix PR (no source changes) + +```bash +# Branch +git fetch origin dev && git checkout dev && git rebase origin/dev +git checkout -b squad/610-fix-broken-link + +# Fix +# ... edit docs/some-file.md ... + +# Validate +npm run build && npm test + +# Commit (no changeset needed — docs only) +git add docs/some-file.md +git diff --cached --stat # verify: 1 file +git commit -m "docs: fix broken link in contributing guide + +Closes #610 + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" + +# Push and PR +git push -u origin squad/610-fix-broken-link +gh pr create --repo bradygaster/squad --base dev \ + --title "docs: fix broken link (#610)" \ + --body "Closes #610" +``` + +### Example: SDK feature PR (source changes) + +```bash +# Branch +git fetch origin dev && git checkout dev && git rebase origin/dev +git checkout -b squad/42-add-profile-api + +# Implement +# ... edit packages/squad-sdk/src/profile/index.ts ... +# ... edit packages/squad-sdk/src/index.ts (re-export) ... + +# Validate +npm run build && npm test && npm run lint + +# Changeset (required — touches packages/squad-sdk/src/) +npx changeset add +# Select: @bradygaster/squad-sdk, minor, "Add profile API" + +# Stage and commit +git add packages/squad-sdk/src/profile/index.ts packages/squad-sdk/src/index.ts .changeset/ +git diff --cached --stat # verify file count +git diff --cached --diff-filter=D --name-only # verify no deletions +git commit -m "feat: add profile API + +Closes #42 + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" + +# Push and PR +git push -u origin squad/42-add-profile-api +gh pr create --repo bradygaster/squad --base dev \ + --title "feat: add profile API (#42)" \ + --body "Closes #42 + +## Summary +Adds profile resolution API to the SDK. + +## Changes +- New module: packages/squad-sdk/src/profile/ +- Re-exported from barrel file" +``` + +--- + +## Anti-Patterns + +- ❌ Branching from `main` (always branch from `dev`) +- ❌ Targeting `main` with PRs (always target `dev`) +- ❌ Using `git add .` or `git add -A` (stage specific files only) +- ❌ Using `git commit -a` (same risk as broad staging) +- ❌ Using `git reset --soft` to squash (contaminates commit with dev delta) +- ❌ Force-pushing to `dev` or `main` (only force-push your own feature branch) +- ❌ Merging your own PR (maintainers merge) +- ❌ Skipping the changeset when source files changed (CI will fail) +- ❌ Self-resolving Copilot threads without addressing the feedback +- ❌ Pushing >1 commit without squashing (readiness check will flag it) +- ❌ Including `.squad/` files in product PRs without intention (scope check warns) +- ❌ Mixing product and infrastructure changes in one PR (create separate PRs) + +--- + +## Readiness Check Gaps & Recommendations + +After analyzing `.github/workflows/squad-ci.yml`, three gaps were identified. Gaps 1 and 3 are now implemented (checks 10 and 11). Gap 2 is deferred. + +### Gap 1: Issue Linkage Check (Check 10) — IMPLEMENTED + +**Problem:** Nothing verifies that the PR body or commit message references an issue (`Closes #N` or `Part of #N`). Orphan PRs are hard to trace. + +**Recommendation:** Add `checkIssueLinkage(prBody, commitMessages)` to `pr-readiness.mjs`. + +```javascript +/** + * Check: Issue linkage. + * @param {string} prBody — PR description text + * @param {Array<{ commit: { message: string } }>} commits + * @returns {{ pass: boolean, detail: string }} + */ +export function checkIssueLinkage(prBody, commits) { + const issuePattern = /(closes|fixes|resolves|part of)\s+#\d+/i; + const bodyHasRef = issuePattern.test(prBody || ''); + const commitHasRef = (commits || []).some( + (c) => issuePattern.test(c.commit?.message || '') + ); + if (bodyHasRef || commitHasRef) { + return { pass: true, detail: 'Issue reference found' }; + } + return { + pass: false, + detail: 'No issue reference — add `Closes #N` to PR body or commit message', + }; +} +``` + +**Integration point:** After check 2 (draft status), before check 3 (branch freshness). Data is already available from the commits and PR body fetched in the orchestrator. + +### Gap 2: No Required Checks Presence Verification + +**Problem:** The CI status check (check 9) verifies that existing checks are green, but doesn't verify that the *expected* set of checks actually ran. If a workflow is misconfigured or skipped, the PR could pass with zero CI checks. + +**Recommendation:** Add `checkRequiredChecksPresent(checkRuns, files)` to `pr-readiness.mjs`. + +```javascript +/** Minimum required check names that must appear for source PRs. */ +export const REQUIRED_CHECKS = ['Squad CI / test']; + +/** + * Check: Required CI checks are present. + * @param {Array<{ name: string }>} checkRuns + * @param {Array<{ filename: string }>} files + * @returns {{ pass: boolean, detail: string }} + */ +export function checkRequiredChecksPresent(checkRuns, files) { + const touchesSource = (files || []).some( + (f) => SOURCE_PATTERN.test(f.filename) + ); + if (!touchesSource) { + return { pass: true, detail: 'No source changes — required checks not enforced' }; + } + const checkNames = new Set((checkRuns || []).map((cr) => cr.name)); + const missing = REQUIRED_CHECKS.filter((name) => !checkNames.has(name)); + if (missing.length > 0) { + return { + pass: false, + detail: `Required check(s) not found: ${missing.join(', ')}`, + }; + } + return { pass: true, detail: 'All required checks present' }; +} +``` + +**Integration point:** After check 9 (CI status). Uses the same `checkRuns` and `files` data already fetched. + +### Gap 3: Protected File Change Detection (Check 11) — IMPLEMENTED + +**Problem:** The repo has zero-dependency bootstrap files that must never import external packages (documented in `copilot-instructions.md`). The `squad-repo-health.yml` workflow runs a bootstrap protection check, but the PR readiness comment doesn't surface it — contributors don't see the warning until they check the separate workflow. + +**Recommendation:** Add `checkProtectedFiles(files)` to `pr-readiness.mjs` as an **informational** check (always passes, like scope clean). + +```javascript +/** Bootstrap files that must remain zero-dependency. */ +export const PROTECTED_FILES = [ + 'packages/squad-cli/src/cli/core/detect-squad-dir.ts', + 'packages/squad-cli/src/cli/core/errors.ts', + 'packages/squad-cli/src/cli/core/gh-cli.ts', + 'packages/squad-cli/src/cli/core/output.ts', + 'packages/squad-cli/src/cli/core/history-split.ts', +]; + +/** + * Check: Protected file changes (informational). + * @param {Array<{ filename: string }>} files + * @returns {{ pass: boolean, detail: string }} + */ +export function checkProtectedFiles(files) { + const touched = (files || []).filter( + (f) => PROTECTED_FILES.includes(f.filename) + ); + if (touched.length === 0) { + return { pass: true, detail: 'No protected bootstrap files changed' }; + } + return { + pass: true, + detail: `⚠️ ${touched.length} protected bootstrap file(s) changed: ${touched.map((f) => f.filename.split('/').pop()).join(', ')} — verify zero-dependency constraint`, + }; +} +``` + +**Integration point:** After check 7 (scope clean). Uses the same `files` data. Informational only — warns but doesn't block. + +### Summary of Recommended Changes + +| Check | Type | Blocks PR? | Status | +|-------|------|------------|--------| +| Issue linkage (Check 10) | Hard gate | Yes | Implemented | +| Required checks present | Hard gate | Yes | Deferred | +| Protected file warning (Check 11) | Informational | No | Implemented | + +All three use data that `pr-readiness.mjs` already fetches — no new API calls needed. Total addition: ~60 lines of check functions + ~10 lines of orchestration wiring. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 1a3813a33..01e18dfad 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -19,6 +19,7 @@ You are **Squad (Coordinator)** — the orchestrator for this project's AI team. - You may NOT generate domain artifacts (code, designs, analyses) — spawn an agent - You may NOT bypass reviewer approval on rejected work - You may NOT invent facts or assumptions — ask the user or spawn an agent who knows + - You may NOT do work yourself — ALWAYS delegate to a team member, even for small tasks. The only exception is Direct Mode (status checks, factual questions, and simple answers from context — see Response Mode Selection). Check: Does `.squad/team.md` exist? (fall back to `.ai-team/team.md` for repos migrating from older installs) - **No** → Init Mode @@ -104,7 +105,7 @@ The `union` merge driver keeps all lines from both sides, which is correct for a **If you wrote code, generated artifacts, or produced domain work without dispatching to an agent, you violated this rule. The coordinator ROUTES — it does not BUILD. No exceptions.** -**On every session start:** Run `git config user.name` to identify the current user, and **resolve the team root** (see Worktree Awareness). Store the team root — all `.squad/` paths must be resolved relative to it. Pass the team root into every spawn prompt as `TEAM_ROOT` and the current user's name into every agent spawn prompt and Scribe log so the team always knows who requested the work. Check `.squad/identity/now.md` if it exists — it tells you what the team was last focused on. Update it if the focus has shifted. +**On every session start:** Run `git config user.name` to identify the current user, and **resolve the team root** (see Worktree Awareness). Store the team root — all `.squad/` paths must be resolved relative to it. Pass the team root and the current datetime (from `` in your system context) into every spawn prompt as `TEAM_ROOT` and `CURRENT_DATETIME` respectively. Pass the current user's name into every agent spawn prompt and Scribe log so the team always knows who requested the work. Check `.squad/identity/now.md` if it exists — it tells you what the team was last focused on. Update it if the focus has shifted. **⚡ Context caching:** After the first message in a session, `team.md`, `routing.md`, and `registry.json` are already in your context. Do NOT re-read them on subsequent messages — you already have the roster, routing rules, and cast names. Only re-read if the user explicitly modifies the team (adds/removes members, changes routing). @@ -330,6 +331,7 @@ description: "{emoji} {Name}: {brief task summary}" prompt: | You are {Name}, the {Role} on this project. TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} WORKTREE_PATH: {worktree_path} WORKTREE_MODE: {true|false} **Requested by:** {current user name} @@ -348,7 +350,7 @@ prompt: | ⚠️ RESPONSE ORDER: After ALL tool calls, write a plain text summary as FINAL output. ``` -For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. {question} TEAM ROOT: {team_root}"` +For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. CURRENT_DATETIME: {current_datetime} — {question} TEAM ROOT: {team_root}"` ### Per-Agent Model Selection @@ -499,7 +501,7 @@ The `sql` tool is **CLI-only**. It does not exist on VS Code, JetBrains, or GitH MCP (Model Context Protocol) servers extend Squad with tools for external services — Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them. -> **Full patterns:** Read `.squad/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, graceful degradation. Read `.squad/templates/mcp-config.md` for config file locations, sample configs, and authentication notes. +> **Config details:** Read `.squad/templates/mcp-config.md` for config file locations, sample configs, and authentication notes. #### Detection @@ -623,15 +625,17 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha **How the Coordinator resolves the team root (on every session start):** -1. Run `git rev-parse --show-toplevel` to get the current worktree root. -2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +1. **Check CWD first** — does `.squad/` exist in the current working directory? + - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. +2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. +3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: ``` git worktree list --porcelain ``` The first `worktree` line is the main working tree. Team root = that path. -3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. @@ -769,6 +773,7 @@ prompt: | {paste contents of .squad/agents/{name}/charter.md here} TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} All `.squad/` paths are relative to this root. PERSONAL_AGENT: {true|false} # Whether this is a personal agent @@ -815,6 +820,7 @@ prompt: | Do the work. Respond as {Name}. ⚠️ OUTPUT: Report outcomes in human terms. Never expose tool internals or SQL. + ⚠️ DATES: When writing dates in any file (decisions, history, logs), use ONLY the CURRENT_DATETIME value above. Never infer or guess the date. AFTER work: 1. APPEND to .squad/agents/{name}/history.md under "## Learnings": @@ -871,6 +877,7 @@ description: "📋 Scribe: Log session & merge decisions" prompt: | You are the Scribe. Read .squad/agents/scribe/charter.md. TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} SPAWN MANIFEST: {spawn_manifest} diff --git a/.github/workflows/squad-insider-publish.yml b/.github/workflows/squad-insider-publish.yml index f57e574e6..acc830de8 100644 --- a/.github/workflows/squad-insider-publish.yml +++ b/.github/workflows/squad-insider-publish.yml @@ -5,30 +5,15 @@ on: branches: - insider +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + permissions: contents: read jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 10 - strategy: - matrix: - node-version: [22] - steps: - - uses: actions/checkout@v4 - - # CI Hardening: Composite action replaces duplicated setup-node + retry - # pattern (item 7). See .github/actions/setup-squad-node/action.yml. - - uses: ./.github/actions/setup-squad-node - with: - node-version: ${{ matrix.node-version }} - - - name: Build - run: npm run build - test: - needs: build runs-on: ubuntu-latest timeout-minutes: 15 strategy: @@ -109,38 +94,11 @@ 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/.github/workflows/squad-insider-release.yml b/.github/workflows/squad-insider-release.yml index 36a1121bf..f266b1fe2 100644 --- a/.github/workflows/squad-insider-release.yml +++ b/.github/workflows/squad-insider-release.yml @@ -4,6 +4,10 @@ on: push: branches: [insider] +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + permissions: contents: write diff --git a/.github/workflows/squad-npm-publish.yml b/.github/workflows/squad-npm-publish.yml index b6e40f1d4..bee98cf8c 100644 --- a/.github/workflows/squad-npm-publish.yml +++ b/.github/workflows/squad-npm-publish.yml @@ -8,6 +8,10 @@ on: description: 'Version to publish (e.g., 0.9.1)' required: true type: string + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false permissions: contents: read id-token: write diff --git a/.github/workflows/squad-preview.yml b/.github/workflows/squad-preview.yml index 9df39e079..da3e308ca 100644 --- a/.github/workflows/squad-preview.yml +++ b/.github/workflows/squad-preview.yml @@ -4,6 +4,10 @@ on: push: branches: [preview] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/.github/workflows/squad-promote.yml b/.github/workflows/squad-promote.yml index 9d315b1d1..ed71bd79c 100644 --- a/.github/workflows/squad-promote.yml +++ b/.github/workflows/squad-promote.yml @@ -10,6 +10,10 @@ on: type: choice options: ['false', 'true'] +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + permissions: contents: write diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml index 6ae0f07fd..fca4f27db 100644 --- a/.github/workflows/squad-release.yml +++ b/.github/workflows/squad-release.yml @@ -4,6 +4,10 @@ on: push: branches: [main] +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + permissions: contents: write diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml index 699fc680f..be3b15230 100644 --- a/.github/workflows/sync-squad-labels.yml +++ b/.github/workflows/sync-squad-labels.yml @@ -7,6 +7,10 @@ on: - '.ai-team/team.md' workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + permissions: issues: write contents: read diff --git a/.gitignore b/.gitignore index 971f14978..0eeb162c3 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,8 @@ docs/src/content/docs/reference/api/ docs/tests/screenshots/ # Squad: SubSquad activation file (local to this machine) .squad-workstream -.squad/.first-run +.squad/.first-run +.squad/.watch-pids # Images folder (root only — don't ignore docs/public/images/) /images/ diff --git a/.squad-templates/mcp-config.md b/.squad-templates/mcp-config.md index 2e361ee4b..f38425e4c 100644 --- a/.squad-templates/mcp-config.md +++ b/.squad-templates/mcp-config.md @@ -2,8 +2,6 @@ MCP (Model Context Protocol) servers extend Squad with tools for external services — Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them. -> **Full patterns:** Read `.squad/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, and graceful degradation. - ## Config File Locations Users configure MCP servers at these locations (checked in priority order): diff --git a/.squad-templates/scribe-charter.md b/.squad-templates/scribe-charter.md index 78d4d971b..308b780e2 100644 --- a/.squad-templates/scribe-charter.md +++ b/.squad-templates/scribe-charter.md @@ -43,7 +43,7 @@ After every substantial work session: - **Exact duplicates:** If two blocks share the same heading, keep the first and remove the rest. - **Overlapping decisions:** Compare block content across all remaining blocks. If two or more blocks cover the same area (same topic, same architectural concern, same component) but were written independently (different dates, different authors), consolidate them: a. Synthesize a single merged block that combines the intent and rationale from all overlapping blocks. - b. Use today's date and a new heading: `### {today}: {consolidated topic} (consolidated)` + b. Use the CURRENT_DATETIME value from your spawn prompt and a new heading: `### {CURRENT_DATETIME}: {consolidated topic} (consolidated)` c. Credit all original authors: `**By:** {Name1}, {Name2}` d. Under **What:**, combine the decisions. Note any differences or evolution. e. Under **Why:**, merge the rationale, preserving unique reasoning from each. diff --git a/.squad/agents/eecom/history.md b/.squad/agents/eecom/history.md index 2b4ee011a..cf00ea8ac 100644 --- a/.squad/agents/eecom/history.md +++ b/.squad/agents/eecom/history.md @@ -4,6 +4,12 @@ ## Learnings +### PR #942 rebase — cherry-pick from insider-based fork branch (2026-04-12) + +**Context:** PR #942 from tamirdresher's fork was retargeted from `insider` to `dev`, causing 29 files in the diff when only 3 commits (4 files relevant to dev) were the actual fix. Cherry-picked the 3 fix commits onto a clean `squad/942-rebase-type-safety` branch from dev, resolving conflicts where insider-only files (skill.ts, cross-package-exports.test.ts) didn't exist on dev. Dropped the `escapeYamlValue` import and APM YAML generation function from init.ts since skill.ts doesn't exist on dev. Opened #963 as the clean replacement, closed #942. + +**Key lesson:** When cherry-picking from an insider-based branch to dev, expect modify/delete conflicts for files that only exist on insider. Always verify the base assumptions of each change — imports referencing insider-only modules must be dropped or adapted. + ### Loop command: second-round review fixes (#767) (2025-07-26) **Context:** Three Copilot review comments on PR #767: (1) `teamRoot` was set to `workTreeRoot` but `.squad/` may live in the main checkout when running inside a git worktree — should derive from `detectSquadDir().path`, (2) `generateLoopFile()` hardcoded the full loop.md scaffold inline, duplicating `templates/loop.md`, (3) docs said `gh` was optional but code hard-requires `gh copilot` unless `--agent-cmd` is passed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff58fd677..a4add3097 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,31 @@ Welcome to Squad development. This guide explains how to build, test, and contri - **Git** with SSH agent (for package resolution) - **gh CLI** (for GitHub integration testing) +## Community Guidelines & Spam Protection + +Our repository uses automated spam detection to maintain a safe, productive community. Here's what you need to know: + +### Spam Detection Guidelines +- **Comment screening** — Malicious links (shortened URLs, file-sharing services) and mass-mentions are monitored +- **Issue evaluation** — New issues are reviewed for spam patterns; suspected spam may be auto-closed +- **Auto-lock stale content** — Issues and PRs inactive for 30+ days are locked to prevent spam on old threads + +### What Gets Flagged +- Shortened URLs (bit.ly, tinyurl, t.co, goo.gl, rb.gy) +- File-sharing links (Dropbox, Google Drive, Mega, MediaFire) +- Crypto/investment scams ("free bitcoin", "guaranteed profit", etc.) +- Adult content patterns +- Mass-mentions (4+ @-mentions in one comment) +- New accounts (< 30 days old) with 0 repos, 0 followers + suspicious content + +### If Your Content Is Flagged +- **Comment not posted** — If your comment contains flagged patterns, it may be held for review +- **Issue closed as spam** (clear violation) — Likely closed with explanation; contact maintainers if you believe this is a mistake +- **Issue labeled "suspicious"** — Flagged for maintainer review but remains open +- **Issue locked** — If inactive for 30+ days, locked to prevent spam replies + +If your legitimate issue/comment is caught by spam detection, please contact a maintainer. We're here to help. + ## Monorepo Structure Squad is an npm workspace monorepo with two packages: @@ -151,10 +176,33 @@ The Co-authored-by trailer is **required** for all commits (added by Copilot CLI 1. Add a changeset: `npx changeset add` (required before PR — see Changesets section) 2. Push your branch: `git push origin {yourusername}/217-readme-help-update` -3. Create a PR with explicit base and head: `gh pr create --base dev --repo bradygaster/squad --head {yourusername}:your-branch` +3. Create a PR **as a draft**: `gh pr create --draft --base dev --repo bradygaster/squad --head {yourusername}:your-branch` 4. Link the issue: Add `Closes #217` to PR description -5. Wait for CI checks to pass -6. Request review from the team (agents will respond via comments) +5. Work on your changes until CI passes and you're satisfied +6. **Mark as "Ready for review"** — this is the handoff signal to the core team (see below) + +### Handoff: Contributor → Core Team + +External contributors don't have write access, so the review-to-merge flow has a handoff point. Here's exactly what happens: + +**Your side (contributor):** + +1. ✅ All required CI checks are green (build, test, lint; changeset/CHANGELOG gate only applies when `packages/squad-cli/src/` or `packages/squad-sdk/src/` files change) +2. ✅ PR is no longer a draft — mark as **"Ready for review"** +3. ✅ Copilot reviewer bot posts its review automatically +4. ✅ Review Copilot's suggestions and manually apply any you agree with in your fork +5. ✅ Push updates to your branch to address Copilot's feedback +6. ✅ If Copilot flags issues you can't resolve, note them in a PR comment + +> **Note:** Copilot review suggestions appear as comments, but the "Commit suggestion" and "Fix with Copilot" buttons require repo write access and won't work for external contributors. Review the suggestions, apply them manually in your fork, and push your changes. + +**Core team side (after you undraft):** + +1. Look for CI-green, undrafted PRs from contributors +2. Address any remaining Copilot review issues (using "Fix with Copilot" or manual fixes) +3. Human review, resolve threads, and merge + +**TL;DR:** Your job is done when the PR is undrafted, CI is green, and you've responded to Copilot suggestions. The core team takes it from there. ### PR Readiness Checklist @@ -170,8 +218,6 @@ 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/README.md b/README.md index f9d201ccb..95c88cc5e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [English](README.md) | [中文](README.zh.md) -**AI agent teams for any project.** One command. A team that grows with your code. +**Human-led AI agent teams for any project.** One command. A team that helps you move faster with your code. [![Status](https://img.shields.io/badge/status-alpha-blueviolet)](#status) [![Platform](https://img.shields.io/badge/platform-GitHub%20Copilot-blue)](#what-is-squad) @@ -13,9 +13,13 @@ ## What is Squad? -Squad gives you an AI development team through GitHub Copilot. Describe what you're building. Get a team of specialists — frontend, backend, tester, lead — that live in your repo as files. They persist across sessions, learn your codebase, share decisions, and get better the more you use them. +Squad gives you a human-directed AI development team through GitHub Copilot. Describe what you're building. Get a team of specialists — frontend, backend, tester, lead — that live in your repo as files. They persist across sessions, learn your codebase, share decisions, and help you move faster without giving up oversight. -It's not a chatbot wearing hats. Each team member runs in its own context, reads only its own knowledge, and writes back what it learned. +Squad is a productivity tool for humans, not a replacement for engineers, reviewers, or decision-makers. People stay accountable for priorities, approvals, and final changes; Squad helps with coordination, repetition, and parallel execution. + +It's not a chatbot wearing hats. Each team member runs in its own context, reads only its own knowledge, and writes back what it learned so the work stays inspectable. + +> **Responsible AI stance** — Squad is built to amplify a human operator with GitHub Copilot, not to remove humans from the loop. Use it to delegate faster, review better, and keep governance close to the code. --- @@ -106,7 +110,7 @@ Use `--force` to re-apply updates even when your installed version already match | `squad link ` | Connect to a remote team | | `squad externalize` | Move `.squad/` state outside the working tree; survives branch switches; use `--key ` for custom project key | | `squad internalize` | Move externalized state back into `.squad/` | -| `squad shell` | Launch interactive shell explicitly | +| `squad shell` | **Deprecated** — Launch interactive shell explicitly. Use `copilot --agent squad` instead. | | `squad export` | Export squad to a portable JSON snapshot | | `squad import ` | Import squad from an export file | | `squad plugin marketplace add\|remove\|list\|browse` | Manage plugin marketplaces | @@ -117,9 +121,9 @@ Use `--force` to re-apply updates even when your installed version already match --- -## Watch Mode — Ralph's Autonomous Polling +## Watch Mode — Ralph's Automated Polling -Ralph continuously polls for work and dispatches agents to handle it. Watch mode is perfect for unmanned squad operations — let agents work while you're away. +Ralph continuously polls for work and dispatches agents to handle it. Watch mode helps a human team stay responsive — Ralph automates triage, execution handoffs, and monitoring, then escalates back to people when judgment or approval is needed. ### Quick Start @@ -171,7 +175,7 @@ Ralph uses an **agent-delegated selection pattern**: 5. The agent **decides which issue to work on** and **how** 6. Ralph monitors execution, logs results, updates issue status -This design gives **agents full autonomy** over issue selection while keeping the polling loop lean. +This design keeps the polling loop lean while letting agents handle issue selection automatically under the team's rules, review gates, and escalation policy. ### Issue Selection & Escalation @@ -271,6 +275,14 @@ Round: 42 / 1200 ## Interactive Shell +> ⚠️ **Deprecated:** The interactive shell (`squad` with no arguments) has been deprecated. For the best Squad experience, use the [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) instead. +> +> ```bash +> copilot --agent squad +> ``` +> +> See [Choose your interface](docs/src/content/docs/get-started/choose-your-interface.md) for current options. + Tired of typing `squad` followed by a command every time? Enter the interactive shell. ### Entering the Shell @@ -331,9 +343,9 @@ Eight working examples from beginner to advanced — casting, governance, stream --- -## Agents Work in Parallel— You Catch Up When You're Ready +## Agents Work in Parallel — You Stay in Control -Squad doesn't work on a human schedule. When you give a task, the coordinator launches every agent that can usefully start — simultaneously. +Squad helps one human coordinate more work at once. When you give a task, the coordinator launches every agent that can usefully start — simultaneously — while you keep priorities, review, and final decisions. ``` You: "Team, build the login page" @@ -345,7 +357,7 @@ You: "Team, build the login page" 📋 Scribe — logging everything... ⎦ ``` -When agents finish, the coordinator immediately chains follow-up work. If you step away, a breadcrumb trail is waiting when you get back: +When agents finish, the coordinator records follow-up work and leaves a breadcrumb trail so you can review what happened with full context: - **`decisions.md`** — every decision any agent made - **`orchestration-log/`** — what was spawned, why, and what happened diff --git a/README.zh.md b/README.zh.md index b8a42227b..a23dc4552 100644 --- a/README.zh.md +++ b/README.zh.md @@ -103,7 +103,7 @@ squad upgrade | `squad copilot` | 添加/移除 Copilot 编码智能体 (@copilot);使用 `--off` 移除,`--auto-assign` 启用自动分配 | | `squad doctor` | 检查环境配置并诊断问题(别名:`heartbeat`) | | `squad link ` | 连接到远程团队仓库 | -| `squad shell` | 显式启动交互式 shell | +| `squad shell` | **已弃用** — 显式启动交互式 shell。请改用 `copilot --agent squad`。 | | `squad export` | 将小队导出为可移植的 JSON 快照 | | `squad import ` | 从导出文件导入小队 | | `squad plugin marketplace add\|remove\|list\|browse` | 管理插件市场 | @@ -116,6 +116,14 @@ squad upgrade ## 交互式 Shell +> ⚠️ **已弃用:** 交互式 shell(`squad` 无参数)已弃用。为了获得最佳的 Squad 体验,请改用 [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli)。 +> +> ```bash +> copilot --agent squad +> ``` +> +> 详见[选择您的界面](docs/src/content/docs/get-started/choose-your-interface.md)了解当前选项。 + 厌倦了每次都输入 `squad` 加命令?进入交互式 shell。 ### 进入 Shell diff --git a/docs/migration/teams-async-adapter.md b/docs/migration/teams-async-adapter.md new file mode 100644 index 000000000..b3ebefc49 --- /dev/null +++ b/docs/migration/teams-async-adapter.md @@ -0,0 +1,129 @@ +# Migration Guide: Teams Adapter — Async Factory + Token Security + +> **Applies to:** Squad SDK ≥ v0.10.0 (PR #768) + +## Breaking Change: `createCommunicationAdapter` is now async + +PR #768 changed `createCommunicationAdapter` from a synchronous function to an async function that returns `Promise`. + +### Why + +The Teams adapter (`teams-graph` channel) requires interactive OAuth authentication (browser PKCE or device-code flow). These operations are inherently asynchronous. Making the factory async ensures all adapters — including those requiring network auth — can be created through the same interface. + +### Before (v0.9.x — synchronous) + +```typescript +import { createCommunicationAdapter } from '@bradygaster/squad-sdk'; + +const adapter = createCommunicationAdapter(repoRoot); +await adapter.postUpdate({ title: 'Hello', body: 'World' }); +``` + +### After (v0.10.x — async) + +```typescript +import { createCommunicationAdapter } from '@bradygaster/squad-sdk'; + +const adapter = await createCommunicationAdapter(repoRoot); +await adapter.postUpdate({ title: 'Hello', body: 'World' }); +``` + +### Migration steps + +1. Add `await` before every `createCommunicationAdapter()` call +2. Ensure the calling function is `async` +3. If the adapter is created at module top-level, wrap it in an async IIFE or move it into an `async` init function + +**Find affected code:** + +```bash +# Find all call sites in your codebase +grep -rn "createCommunicationAdapter" --include="*.ts" --include="*.js" +``` + +**Common patterns:** + +```typescript +// ❌ Module-level (breaks) +const adapter = createCommunicationAdapter(root); + +// ✅ Module-level (works) +let adapter: CommunicationAdapter; +async function init() { + adapter = await createCommunicationAdapter(root); +} + +// ✅ Inside an async function (simplest fix) +async function setup() { + const adapter = await createCommunicationAdapter(root); + // ... +} +``` + +--- + +## New: Token Security Improvements + +These changes ship alongside the async migration and require no code changes — they're internal to the Teams adapter. + +### 1. Identity-scoped token cache + +**Before:** All tenants shared a single token file (`~/.squad/teams-tokens.json`). In multi-tenant environments, one tenant's token could be served to another. + +**After:** Tokens are stored per-identity at `~/.squad/teams-tokens-{hash}.json`. The hash is derived from both the configured `tenantId` and `clientId`, preventing cross-tenant and cross-app token reuse. The actual authenticated identity (`tid` and `oid` from the JWT) is stored as metadata for audit. Legacy token files are automatically migrated on first use and then deleted. + +**Configuration:** Set `tenantId` in your `.squad/config.json` to explicitly scope tokens: + +```json +{ + "communications": { + "channel": "teams-graph", + "adapterConfig": { + "teams-graph": { + "tenantId": "contoso.onmicrosoft.com", + "recipientUpn": "bradyg@contoso.com" + } + } + } +} +``` + +> **Note:** When using the default multi-tenant authority (`organizations`), all users on the same OS account share one cache file per `clientId`. If you switch Microsoft accounts, call `logout()` or wait for token expiry. For true per-account isolation, configure an explicit `tenantId`. + +### 2. Explicit logout (`logout()`) + +The Teams adapter now exposes `logout()` for explicit credential cleanup: + +```typescript +const adapter = await createCommunicationAdapter(root); + +// ... use the adapter ... + +// Logout: clears in-memory tokens + deletes cached token file +if (adapter.logout) { + await adapter.logout(); +} +``` + +This is a local credential purge — it does not revoke server-side tokens (not supported for public-client AAD flows). The `CommunicationAdapter` interface now includes an optional `logout?(): Promise` method. + +### 3. Device-code timeout guard + +**Before:** The device-code auth flow timeout was server-controlled (could be arbitrarily long). + +**After:** A 15-minute maximum timeout is enforced client-side. The poll interval is also clamped to 2–30 seconds to prevent both rapid polling and excessively slow polling from malformed server responses. + +### 4. Stale token cleanup + +**Before:** On token refresh failure, stale tokens remained on disk and would be reloaded on the next attempt. + +**After:** On permanent auth errors (`invalid_grant`, `interaction_required`, `consent_required`), the stale token file is deleted before re-authenticating. Transient failures (network errors, 5xx) preserve the token for retry. + +--- + +## Checklist + +- [ ] Updated all `createCommunicationAdapter()` calls to use `await` +- [ ] Verified calling functions are `async` +- [ ] Set explicit `tenantId` in config (recommended for multi-tenant) +- [ ] Tested auth flow still works after upgrade diff --git a/docs/src/components/Search.astro b/docs/src/components/Search.astro index cac07914b..dde08a210 100644 --- a/docs/src/components/Search.astro +++ b/docs/src/components/Search.astro @@ -11,9 +11,9 @@ const base = import.meta.env.BASE_URL; - + @@ -30,7 +30,7 @@ const base = import.meta.env.BASE_URL; @@ -39,7 +39,7 @@ const base = import.meta.env.BASE_URL;
- Start typing to search… + Start typing to search...
@@ -48,15 +48,9 @@ const base = import.meta.env.BASE_URL; diff --git a/docs/src/components/Sidebar.astro b/docs/src/components/Sidebar.astro index a631d562c..788df5660 100644 --- a/docs/src/components/Sidebar.astro +++ b/docs/src/components/Sidebar.astro @@ -62,19 +62,86 @@ const base = import.meta.env.BASE_URL; diff --git a/docs/src/content/blog/015-wave-2-the-repl-moment.md b/docs/src/content/blog/015-wave-2-the-repl-moment.md index fe9aab865..f55c35e94 100644 --- a/docs/src/content/blog/015-wave-2-the-repl-moment.md +++ b/docs/src/content/blog/015-wave-2-the-repl-moment.md @@ -10,6 +10,8 @@ hero: "We built an interactive shell that makes you forget you're talking to age # Wave 2: The REPL Moment +> 📌 **Archive note:** The interactive shell described in this post has been deprecated. For the best Squad experience, use the [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli). See [Choose your interface](/docs/get-started/choose-your-interface/) for current options. + > ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. diff --git a/docs/src/content/docs/concepts/architecture.md b/docs/src/content/docs/concepts/architecture.md index 991891e3b..b0ea9739d 100644 --- a/docs/src/content/docs/concepts/architecture.md +++ b/docs/src/content/docs/concepts/architecture.md @@ -65,6 +65,7 @@ Ralph is the work monitor. He watches your GitHub or GitLab issues, tracks work ## Learn more -- [**Work routing**](../features/routing) — How the coordinator decides which agents to spawn -- [**Memory and knowledge**](memory-and-knowledge) — How decisions, skills, and history persist -- [**Parallel work**](parallel-work) — How agents work simultaneously without conflicts +- [**Your Team**](./your-team.md) — How agents form, specialize, and work together +- [**Work routing**](../features/routing.md) — How the coordinator decides which agents to spawn +- [**Memory and knowledge**](memory-and-knowledge.md) — How decisions, skills, and history persist +- [**Parallel work**](parallel-work.md) — How agents work simultaneously without conflicts diff --git a/docs/src/content/docs/concepts/github-workflow.md b/docs/src/content/docs/concepts/github-workflow.md index b4c1d06ce..5bbd4826e 100644 --- a/docs/src/content/docs/concepts/github-workflow.md +++ b/docs/src/content/docs/concepts/github-workflow.md @@ -264,3 +264,11 @@ squad watch --interval 5 ``` Starts persistent local polling — checks GitHub every 5 minutes for new work and triages automatically. + +--- + +## See Also + +- [Your Team](./your-team.md) — How work routes to the right agent +- [Work Routing](../features/routing.md) — Domain routing and agent assignment +- [Parallel Work](./parallel-work.md) — Multi-agent parallel execution on batched issues diff --git a/docs/src/content/docs/concepts/memory-and-knowledge.md b/docs/src/content/docs/concepts/memory-and-knowledge.md index 4b985840d..e0cad2ff8 100644 --- a/docs/src/content/docs/concepts/memory-and-knowledge.md +++ b/docs/src/content/docs/concepts/memory-and-knowledge.md @@ -290,3 +290,11 @@ Search past decisions for database choices ``` Finds historical decisions related to a specific topic or keyword. + +--- + +## See Also + +- [Your Team](./your-team.md) — How agents use shared memory to coordinate +- [Architecture](./architecture.md) — How the coordinator and agents share state +- [Parallel Work](./parallel-work.md) — How agents maintain consistency while working in parallel diff --git a/docs/src/content/docs/concepts/parallel-work.md b/docs/src/content/docs/concepts/parallel-work.md index 6aa64103c..999a3fba7 100644 --- a/docs/src/content/docs/concepts/parallel-work.md +++ b/docs/src/content/docs/concepts/parallel-work.md @@ -361,3 +361,11 @@ Use the main worktree's Squad team ``` Creates a symlink so this worktree shares the main checkout's `.squad/` state. + +--- + +## See Also + +- [Your Team](./your-team.md) — How agents form and specialize for different roles +- [Architecture](./architecture.md) — How the coordinator orchestrates parallel execution +- [Memory & Knowledge](./memory-and-knowledge.md) — How agents share context across parallel work diff --git a/docs/src/content/docs/concepts/your-team.md b/docs/src/content/docs/concepts/your-team.md index 64d586d13..78b6ce188 100644 --- a/docs/src/content/docs/concepts/your-team.md +++ b/docs/src/content/docs/concepts/your-team.md @@ -345,3 +345,12 @@ Who handles authentication work? ``` Coordinator checks routing and skills, reports the responsible agent(s). + +--- + +## See Also + +- [Architecture](./architecture.md) — How the coordinator, agents, and shared memory work together +- [Work Routing](../features/routing.md) — How work gets assigned to the right agent +- [Parallel Work & Models](./parallel-work.md) — Agents working simultaneously without conflicts +- [Memory & Knowledge](./memory-and-knowledge.md) — How decisions and agent history persist diff --git a/docs/src/content/docs/features/built-in-roles.md b/docs/src/content/docs/features/built-in-roles.md index 757c24c3e..6723078af 100644 --- a/docs/src/content/docs/features/built-in-roles.md +++ b/docs/src/content/docs/features/built-in-roles.md @@ -94,3 +94,11 @@ $ squad roles --search "security" # search by keyword ## Attribution Built-in role content is adapted from [agency-agents](https://github.com/msitarzewski/agency-agents) by AgentLand Contributors, released under the MIT License. + +--- + +## See Also + +- [Your Team](../concepts/your-team.md) — Team setup, roster, and role composition +- [Architecture](../concepts/architecture.md) — How agents with specific roles are orchestrated +- [Work Routing](./routing.md) — How work routes to agents based on their roles diff --git a/docs/src/content/docs/features/routing.md b/docs/src/content/docs/features/routing.md index 352862650..3e8478108 100644 --- a/docs/src/content/docs/features/routing.md +++ b/docs/src/content/docs/features/routing.md @@ -169,3 +169,11 @@ Coordinator explains the routing decision based on pattern match or skill fit. Fenster, implement the new search API. Hockney, write integration tests for it. ``` Named routing to two agents. Both spawn in parallel. + +--- + +## See Also + +- [Your Team](../concepts/your-team.md) — How team members and roles are defined +- [Architecture](../concepts/architecture.md) — How the coordinator uses routing to dispatch work +- [Parallel Work](../concepts/parallel-work.md) — Multi-agent parallel execution diff --git a/docs/src/content/docs/features/state-backends.md b/docs/src/content/docs/features/state-backends.md new file mode 100644 index 000000000..db40041bf --- /dev/null +++ b/docs/src/content/docs/features/state-backends.md @@ -0,0 +1,285 @@ +# State Backends + +> ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. + + +**Try this to use git-notes for state storage:** +```bash +squad watch --state-backend git-notes +``` + +**Try this to use an orphan branch:** +```bash +squad watch --state-backend orphan +``` + +**Try this to set a persistent default:** +```bash +echo '{ "stateBackend": "git-notes" }' > .squad/config.json +``` + +Squad supports multiple **state backends** for storing `.squad/` state. Each backend determines _where_ and _how_ decisions, skills, agent memories, and session logs are persisted — without changing how agents interact with the data. + +--- + +## The Problem + +The default **worktree** backend stores `.squad/` state as regular files in the working tree. This works well for most workflows, but has trade-offs: + +- **Branch pollution:** `.squad/` files appear in diffs and PRs +- **Branch-switch loss:** State can be lost when switching branches (if not committed) +- **Merge conflicts:** Multiple branches modifying `.squad/` files can conflict + +State backends solve this by moving `.squad/` data into Git-native structures that live outside the working tree. + +--- + +## Available Backends + +### Worktree (default) + +State lives as regular files in `.squad/` inside the working tree. This is the standard behavior — what you get out of the box. + +```bash +squad watch --state-backend worktree +``` + +**Pros:** +- Simple and familiar — files on disk +- Easy to inspect, edit, and commit +- Works with all Git tools and IDEs + +**Cons:** +- Files appear in `git status` and diffs +- Branch switches can lose uncommitted state + +**Best for:** Most projects, especially when you want squad state committed alongside code. + +--- + +### Git Notes + +State is stored in [Git notes](https://git-scm.com/docs/git-notes) under `refs/notes/squad`. Notes are attached to `HEAD`, keeping data associated with commits but invisible in the working tree. + +```bash +squad watch --state-backend git-notes +``` + +**How it works:** +- All state is serialized as a single JSON blob attached as a note on `HEAD` +- Reading loads the JSON, writing updates and reattaches it +- Notes travel with `git push` / `git fetch` when configured (see [Sharing](#sharing-git-notes-state)) + +**Pros:** +- Working tree stays completely clean — no `.squad/` files +- State is associated with specific commits +- No merge conflicts from `.squad/` files in PRs + +**Cons:** +- State is per-commit — switching to a different commit loses the note context +- Requires `git notes` familiarity for debugging +- Not human-readable without `git notes show` + +**Best for:** Repos where you want zero `.squad/` files in the working tree or PRs. + +#### Sharing Git Notes State + +By default, Git doesn't push notes. To share git-notes state across clones: + +```bash +# Push notes +git push origin refs/notes/squad + +# Fetch notes +git fetch origin refs/notes/squad:refs/notes/squad +``` + +Or configure automatic fetch in `.git/config`: + +```ini +[remote "origin"] + fetch = +refs/notes/squad:refs/notes/squad +``` + +--- + +### Orphan Branch + +State lives on a dedicated orphan branch (`squad-state` by default). The branch has no common history with your main branches — it's a completely separate tree used only for squad data. + +```bash +squad watch --state-backend orphan +``` + +**How it works:** +- An orphan branch `squad-state` is created automatically on first write +- Each state file is stored as a blob in the branch's tree +- Reads use `git show squad-state:`, writes create new commits on the branch +- The branch is never checked out — all operations use Git plumbing commands + +**Pros:** +- Working tree stays clean +- State is versioned with full Git history +- Easy to inspect: `git log squad-state`, `git show squad-state:decisions.md` +- Pushes/fetches with normal branch operations + +**Cons:** +- An extra branch in your repository +- Slightly more complex than worktree for debugging +- Concurrent writes to the branch can conflict (single-writer recommended) + +**Best for:** Teams who want Git-versioned state without polluting the main branch history. + +--- + +## Configuration + +### CLI Flag (per-invocation) + +Pass `--state-backend` to `squad watch` or `squad triage`: + +```bash +squad watch --state-backend git-notes +squad triage --state-backend git-notes +squad watch --state-backend orphan +squad watch --state-backend worktree +``` + +### Config File (persistent) + +Set a default in `.squad/config.json`: + +```json +{ + "stateBackend": "git-notes" +} +``` + +This persists across invocations. The CLI flag overrides the config file when both are present. + +### Priority Order + +| Priority | Source | Example | +|----------|--------|---------| +| 1 (highest) | CLI flag | `--state-backend orphan` | +| 2 | `.squad/config.json` | `"stateBackend": "orphan"` | +| 3 (default) | Built-in default | `worktree` | + +### Fallback Behavior + +If a non-default backend fails to initialize (e.g., Git is not available, permissions issue), Squad automatically falls back to the **worktree** backend with a warning: + +``` +Warning: State backend 'git-notes' failed: . Falling back to 'worktree'. +``` + +--- + +## Comparison + +| Feature | Worktree | Git Notes | Orphan Branch | +|---------|----------|-----------|---------------| +| Working tree clean | ❌ | ✅ | ✅ | +| Appears in PRs | Yes (if committed) | No | No | +| Human-readable on disk | ✅ Files | ❌ JSON blob | ⚠️ Via `git show` | +| Git history | Via normal commits | Per-note | Per-branch commits | +| Branch-switch safe | ❌ (if uncommitted) | ⚠️ | ✅ | +| Easy to inspect | ✅ `cat .squad/...` | ⚠️ `git notes show` | ⚠️ `git show squad-state:...` | +| Sharing across clones | Normal push/pull | Requires notes fetch config | Normal branch push/pull | +| Concurrent-write safe | ✅ (filesystem) | ⚠️ (last writer wins) | ⚠️ (single writer) | + +--- + +## Inspecting State + +### Worktree + +```bash +cat .squad/decisions.md +ls .squad/skills/ +``` + +### Git Notes + +```bash +# Show all state as JSON +git notes --ref=squad show HEAD + +# Pretty-print +git notes --ref=squad show HEAD | python -m json.tool +``` + +### Orphan Branch + +```bash +# List all state files +git ls-tree --name-only -r squad-state + +# Read a specific file +git show squad-state:decisions.md + +# View commit history +git log --oneline squad-state +``` + +--- + +## SDK Usage + +The state backend is available programmatically via the Squad SDK: + +```typescript +import { + resolveStateBackend, + type StateBackend, +} from '@bradygaster/squad-sdk'; + +// Resolve backend from config + CLI override +const backend: StateBackend = resolveStateBackend( + '.squad', // squadDir + process.cwd(), // repoRoot + 'git-notes' // optional CLI override +); + +// Use the backend +backend.write('decisions.md', '# Decisions\n...'); +const content = backend.read('decisions.md'); +const exists = backend.exists('skills/my-skill/SKILL.md'); +const entries = backend.list('skills'); +``` + +All backends implement the same `StateBackend` interface: + +```typescript +interface StateBackend { + read(relativePath: string): string | undefined; + write(relativePath: string, content: string): void; + exists(relativePath: string): boolean; + list(relativeDir: string): string[]; + readonly name: string; +} +``` + +--- + +## Security + +State backends include hardening against common injection attacks: + +- **Path traversal:** `..` segments are rejected +- **Null byte injection:** `\0` characters are rejected +- **Newline injection:** `\n` and `\r` characters are rejected (prevents Git plumbing manipulation) +- **Tab injection:** `\t` characters are rejected (prevents mktree format corruption) +- **Empty segments:** Double slashes (`//`) are rejected + +All validation is centralized in `validateStateKey()` and applied uniformly across all backends. + +--- + +## Notes + +- State backends are **opt-in** — the default is `worktree` (no behavior change) +- All backends implement the same interface — agents don't know or care which backend is active +- The `external` backend type exists as a stub for future external storage (see [External State](./external-state.md)) +- State backends are available in the **insider** release channel (`@bradygaster/squad-cli@insider`) +- 30+ tests cover all backends including security hardening scenarios diff --git a/docs/src/content/docs/get-started/choose-your-interface.md b/docs/src/content/docs/get-started/choose-your-interface.md index 006c49b5d..a0c1d9a07 100644 --- a/docs/src/content/docs/get-started/choose-your-interface.md +++ b/docs/src/content/docs/get-started/choose-your-interface.md @@ -60,9 +60,13 @@ See [CLI Reference](../reference/cli.md) for all commands. ### Interactive shell (`squad start` / `squad shell`) -REPL mode for conversational interaction directly via the Squad CLI. Enter `squad` with no arguments to start a persistent shell session. See [Interactive Shell Guide](../guide/shell.md). +> ⚠️ **Deprecated:** The interactive shell is no longer recommended. Use [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) instead for a richer agent experience. +> +> ```bash +> copilot --agent squad +> ``` -This works, but **GitHub Copilot CLI is recommended** — richer agent experience, better tools, full MCP integration. +REPL mode for conversational interaction directly via the Squad CLI. Enter `squad` with no arguments to start a persistent shell session. ### SDK (`@bradygaster/squad-sdk`) @@ -104,16 +108,16 @@ See [Copilot Coding Agent](../features/copilot-coding-agent.md) for setup. Not every feature works everywhere. Here's what's available where: -| Feature | GitHub Copilot CLI | VS Code | Squad CLI | SDK | -|---------|:------------------:|:-------:|:---------:|:---:| -| Agent spawning | ✅ | ✅ | ✅ (via shell) | ✅ | -| Ralph / work monitoring | ✅ | ✅ | ✅ (`squad watch`) | ✅ | -| Per-spawn model selection | ✅ | ⚠️ (session model only) | ✅ | ✅ | -| Background execution | ✅ | ⚠️ (parallel sync) | ✅ | ✅ | -| SQL tool | ✅ | ❌ | ✅ | ✅ | -| Aspire dashboard | ❌ | ❌ | ✅ | ❌ | -| `squad doctor` diagnostics | ❌ | ❌ | ✅ | ✅ | -| Issue assignment to `@copilot` | ❌ | ❌ | ✅ (setup) | ❌ | +| Feature | GitHub Copilot CLI | VS Code | Squad CLI | Interactive shell | SDK | +|---------|:------------------:|:-------:|:---------:|:--------:|:---:| +| Agent spawning | ✅ | ✅ | ✅ | ⚠️ (deprecated) | ✅ | +| Ralph / work monitoring | ✅ | ✅ | ✅ (`squad watch`) | ❌ | ✅ | +| Per-spawn model selection | ✅ | ⚠️ (session model only) | ✅ | ❌ | ✅ | +| Background execution | ✅ | ⚠️ (parallel sync) | ✅ | ❌ | ✅ | +| SQL tool | ✅ | ❌ | ✅ | ❌ | ✅ | +| Aspire dashboard | ❌ | ❌ | ✅ | ❌ | ❌ | +| `squad doctor` diagnostics | ❌ | ❌ | ✅ | ❌ | ✅ | +| Issue assignment to `@copilot` | ❌ | ❌ | ✅ (setup) | ❌ | ❌ | **Legend:** - ✅ Fully supported diff --git a/docs/src/content/docs/guide.md b/docs/src/content/docs/guide.md index b694cff21..98ccaeb47 100644 --- a/docs/src/content/docs/guide.md +++ b/docs/src/content/docs/guide.md @@ -16,7 +16,7 @@ It is not a chatbot wearing hats. Each team member is spawned as a real sub-agen - Initial setup: `squad init` - Build from config: `squad build` - Diagnostics: `squad doctor` -- Interactive shell: `squad shell` +- Interactive shell: `squad shell` — **Deprecated** (use `copilot --agent squad`) - Continuous triage: `squad triage --interval 10` - Watch mode: `squad watch` - Aspire dashboard: `squad aspire` @@ -547,7 +547,7 @@ Squad maintains a clear ownership model: | `squad build` | Generate `.squad/` from `squad.config.ts` | | `squad build --check` | Validate generated files match disk (for CI) | | `squad doctor` | Run 9 setup validation checks | -| `squad shell` | Enter the interactive shell | +| `squad shell` | **Deprecated** — Enter the interactive shell (use `copilot --agent squad`) | | `squad triage` | Run a single triage pass | | `squad triage --interval 10` | Continuous triage every 10 minutes | | `squad watch` | Ralph watchdog mode | diff --git a/docs/src/content/docs/guide/build-autonomous-agent.md b/docs/src/content/docs/guide/build-autonomous-agent.md index 9c5baf1ad..5e550ff79 100644 --- a/docs/src/content/docs/guide/build-autonomous-agent.md +++ b/docs/src/content/docs/guide/build-autonomous-agent.md @@ -535,3 +535,11 @@ streaming.clear(); - Read the [SDK reference](/reference/sdk/) for the complete API surface - See the [extensibility guide](/guide/extensibility/) for where your agent fits in the Squad ecosystem - Check the [Aspire dashboard scenario](/scenarios/aspire-dashboard/) for observability setup + +--- + +## See Also + +- [Your Team](../concepts/your-team.md) — Agent roles, charters, and team composition +- [Architecture](../concepts/architecture.md) — How the coordinator orchestrates work +- [SDK Reference](../reference/sdk.md) — SDK API for autonomous agents diff --git a/docs/src/content/docs/guide/personal-squad.md b/docs/src/content/docs/guide/personal-squad.md index 36f4e1676..5f71faf3a 100644 --- a/docs/src/content/docs/guide/personal-squad.md +++ b/docs/src/content/docs/guide/personal-squad.md @@ -2,6 +2,9 @@ > ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. +:::tip[Git helps protect your project state] +We strongly recommend using git to version-control your project `.squad/` directory. Every session writes to it — decisions, logs, orchestration history. Agent charters, skills, and casting histories live in your personal squad directory (not committed to projects). Without version control, context window resets, crashes, or accidental deletions could be unrecoverable. If git isn't an option, regular manual backups to external storage or cloud sync can help protect your work. +::: **Try this:** ```bash @@ -310,11 +313,26 @@ We're building in the open. If something feels off, [open an issue](https://gith ## Tips -- **Start with one project.** Get comfortable with the personal squad on one repo before connecting others. The value compounds, but so does confusion if something's misconfigured. -- **Commit project `.squad/` but not personal squad directory.** The project-local state (decisions, logs) belongs in version control. Your global identity is personal — keep it out of repos. +- **Git-first workflow is strongly recommended.** Commit your project `.squad/` directory to git after any session. Without version control: + - Session state could be lost on crashes, context resets, or machine reboots + - Project-local decisions and logs are at risk + - Accidental deletions may be unrecoverable + - Cross-project context could vanish + + Think of git as your insurance policy for your project's local state. It's a good habit: after a session ends, run `git add .squad/ && git commit -m "squad: project state"`. (Agent histories, skills, and team identity are stored in your personal squad directory — not in project repos.) + +- **Commit project `.squad/` but not personal squad directory.** The project-local state (decisions, logs) belongs in version control. Your global identity (agents, skills, casting) is personal — keep it out of repos. + ```bash + # DO THIS + git add .squad/ + git commit -m "squad: project state" + + # DON'T commit your personal squad directory (~/.config/squad/ or similar) — it's machine-specific + ``` + - **Check status anytime.** `squad status` shows your global squad directory and which projects are connected. - **Skills are the payoff.** The more projects you work across, the more skills accumulate. After a month, your agents have a real knowledge base tailored to how *you* build software. -- **It's just files.** Your personal squad directory is a folder on your machine. You can browse it, edit it, back it up, copy it to another machine manually. No magic, no cloud, no lock-in. +- **It's just files.** Your personal squad directory is a folder on your machine. You can browse it, edit it, back it up, copy it to another machine manually. No magic, no cloud, no lock-in. But **always back up or version-control your `.squad/` state**. - **Global install matters.** `npm install -g @bradygaster/squad-cli` gives you the `squad` command everywhere. Without it, you'd need `npx` in each project. Global CLI + global squad = full portability. --- diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 6c47ae2da..fcf9ceeb3 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -26,7 +26,7 @@ squad init | Command | Description | Requires `.squad/` | |---------|-------------|:------------------:| -| `squad` | Enter interactive shell (no args) | No | +| `squad` | **Deprecated** — Enter interactive shell (no args). Use `copilot --agent squad` instead. | No | | `squad init` | Initialize Squad in the current repo (idempotent — safe to run multiple times) | No | | `squad init --global` | Create a personal squad in your platform-specific directory | No | | `squad init --mode remote ` | Initialize linked to a remote team root (dual-root mode) | No | @@ -52,6 +52,7 @@ squad init | `squad watch --max-concurrent N` | Max parallel issues per round (default: 1) | Yes | | `squad watch --timeout N` | Per-issue timeout in minutes (default: 30) | Yes | | `squad watch --copilot-flags "..."` | Extra flags for Copilot CLI | Yes | +| `squad shell` | **Deprecated** — Launch interactive shell explicitly. Use `copilot --agent squad` instead. | No | | `squad copilot` | Add the @copilot coding agent to the team | Yes | | `squad copilot --off` | Remove @copilot from the team | Yes | | `squad copilot --auto-assign` | Enable auto-assignment for @copilot | Yes | diff --git a/docs/src/content/docs/reference/compaction-recovery.md b/docs/src/content/docs/reference/compaction-recovery.md new file mode 100644 index 000000000..7d0f142b4 --- /dev/null +++ b/docs/src/content/docs/reference/compaction-recovery.md @@ -0,0 +1,146 @@ +# Compaction Recovery + +> Recovery mechanism for when conversation context is compacted. + +The Coordinator writes a recovery checkpoint to `.squad/sessions/{session-id}.json` after each agent batch. If prior messages are missing upon context resumption (detected by the Coordinator), it reads the checkpoint to recover its place in the workflow without losing the plan. + +## Why Compaction Recovery Matters + +Large conversations can exceed the Coordinator's context window. When this happens: +1. Your LLM compacts prior messages (they're summarized or removed) +2. The Coordinator loses its working memory of what happened +3. **Without recovery:** Coordinator might replay earlier steps or lose the plan +4. **With recovery:** Coordinator reads the session state checkpoint and continues exactly where it left off + +## Session State Checkpoint + +### What It Contains + +A minimal checkpoint answering: **"What should the Coordinator do next?"** + +Example: +```markdown +# Session State — 2026-04-08T14:50:00Z + +**Last Completed Step:** 8 (Results persisted to orchestration log) + +**Next Action:** +- Spawn Scribe to summarize decisions made during this session + +**Workflow Phase:** Post-agent-work + +**Context:** Three agents completed: API Agent (refactored endpoints), Frontend Agent (updated components), Tests Agent (added coverage) +``` + +### File Location + +``` +.squad/sessions/{session-id}.json +``` + +This file is **temporary** — it's overwritten after each agent batch and is listed in `.gitignore` to prevent accidental commits. + +## Compaction Recovery Flow + +### Detection +The Coordinator detects context compaction when: +- Prior conversation turns are missing from the context window +- Earlier agent results are no longer visible +- Memory gaps appear in the workflow timeline + +### Recovery Steps + +1. **Read Session State** + ``` + Open .squad/sessions/{session-id}.json + Parse "nextAction" field + ``` + +2. **Skip Replayed Steps** + - Do NOT re-run steps already recorded in the checkpoint + - Use the orchestration log to verify what happened + - Resume at the "nextAction" field + +3. **Continue Workflow** + - Execute the next action + - Update the checkpoint after each batch + - Proceed normally + +### Example Recovery + +**Before Compaction:** +``` +Coordinator spawned: API Agent → Frontend Agent → Tests Agent +Results persisted to orchestration log +(context is now full) +``` + +**Context Compaction Occurs:** +``` +Session context is pruned to fit within token limit +Earlier messages are removed or summarized +Coordinator's memory of agent work is gone +``` + +**Upon Context Resumption:** +``` +Coordinator notices missing prior messages +Reads .squad/sessions/{session-id}.json +Finds: "lastCompletedStep": 8 +Finds: "nextAction": "Spawn Scribe to summarize decisions" +Coordinator skips steps 1-8, jumps directly to spawning Scribe +``` + +## Observable Behavior + +- Session state checkpoint is written after each agent batch (step 8 in post-work flow) +- Checkpoint persists through context compaction +- Upon resumption, Coordinator continues the post-work flow without replaying earlier steps +- User sees no interruption — recovery is transparent + +## Session State Format + +The session state checkpoint includes: +- **Timestamp:** ISO 8601 format (when checkpoint was created) +- **Last Completed Step:** Number of the most recent workflow step +- **Next Action:** One bullet point describing the next action +- **Workflow Phase:** Current phase (e.g., "post-agent-work", "pre-spawn") +- **Context:** Brief summary of what happened (for debugging) + +Example: +```markdown +# Session State — 2026-04-08T15:12:30Z + +**Last Completed Step:** 8 (Orchestration log updated) + +**Next Action:** +- Review agent results and determine if any follow-up work is needed + +**Workflow Phase:** Post-agent-work + +**Context:** +Agents spawned: API Agent (completed successfully), Frontend Agent (completed with warnings) +API Agent modified: src/routes/users.ts, src/routes/__tests__/users.test.ts +Frontend Agent modified: src/components/UserProfile.tsx +Warnings: Frontend Agent flagged 2 deprecated React APIs +``` + +## Checkpoint Limitations + +The session state checkpoint is **NOT**: +- Authoritative for architectural decisions (use `.squad/decisions.md` instead) +- Authoritative for work routing (use `.squad/routing.md` instead) +- A permanent archive (it's overwritten after each batch) +- A detailed work log (use the orchestration log for details) + +It is **ONLY** a breadcrumb to help the Coordinator resume at the right place. + +## Related Concepts + +- **Result Persistence** — Immediate archival of agent results to disk before context expires +- **Orchestration Log** — Timestamped records of every agent's work (`.squad/orchestration-log/`) + +## See Also + +- [Result Persistence](./result-persistence.md) — How agent results are archived +- [Coordinator Restraint Rules](./coordinator-restraint.md) — How Coordinator avoids over-managing agents diff --git a/docs/src/content/docs/reference/coordinator-restraint.md b/docs/src/content/docs/reference/coordinator-restraint.md new file mode 100644 index 000000000..f61465cc1 --- /dev/null +++ b/docs/src/content/docs/reference/coordinator-restraint.md @@ -0,0 +1,75 @@ +# Coordinator Restraint Rules + +> Principles that balance the Coordinator's proactive execution with respectful boundaries. + +After dispatching agents, the Coordinator follows 6 restraint rules to avoid over-managing agents, duplicating their work, or spawning unsolicited follow-ups. + +## The 6 Rules + +### 1. No Context Re-explanation +**Rule:** Do NOT re-explain context agents already know. + +**Why:** Agents read their charter, your decisions, and your routing rules before work starts. Repeating context wastes output space and signals mistrust. + +**Observable Behavior:** +- Coordinator output focuses on what agents did, not reminding them what they know +- No sentences like "Remember, you're the API lead, so you need to..." + +### 2. Do NOT Intervene While Agents Run +**Rule:** Do NOT intervene while an agent is still running. + +**Why:** Agents need uninterrupted focus. Jumping in mid-work breaks their reasoning and creates context conflicts. + +**Observable Behavior:** +- If an agent is still in-progress, Coordinator waits for completion before responding +- No "I notice you're doing X, have you considered Y?" messages mid-run + +### 3. Present Output Directly +**Rule:** Do NOT summarize or rephrase agent output — present it directly. + +**Why:** Coordinator narration adds noise and can misrepresent what agents intended to communicate. + +**Observable Behavior:** +- Agent output appears in responses with minimal framing (one sentence max) +- No "To summarize what {agent} said:" preambles +- No "I think they meant..." interpretations + +### 4. No Unsolicited Analysis +**Rule:** Do NOT add unsolicited analysis without user request. + +**Why:** Users didn't ask for Coordinator opinion. Agents already provided their analysis; additional commentary is noise. + +**Observable Behavior:** +- Coordinator refrains from verbose summaries or "what I think this means" +- Analysis only appears when user explicitly asks: "What do you make of this?" or "Analyze this result" + +### 5. No Follow-up Agents Unless Mandated +**Rule:** Do NOT spawn follow-up agents unless explicitly requested, mandated, or part of a declared dependency chain. + +**Why:** Agents have limited spawns per session. Over-zealous orchestration burns budget and violates user agency. + +**Observable Behavior:** +- No unsolicited chain reactions (e.g., "Now I'll spawn Scribe to document this") +- Follow-up agents only spawn if: + - User asks: "Please have Agent X review this" + - Routing rules mandate it: "On merge, always spawn Ralph" + - Agent declares dependencies: "Depends on: API agent" + +### 6. Brief Coordinator Commentary +**Rule:** Keep coordinator commentary to 1-2 sentences maximum. + +**Why:** Brevity respects user and agent time. Long preambles distract from agent results. + +**Observable Behavior:** +- Coordinator framing is concise: "Agent completed the task" or "Here are the results:" +- No multi-sentence narratives about what happened or why + +## Enforcement + +These rules are hardcoded into the Coordinator prompt and verified by: +- **Session state checkpoint:** Tracks Coordinator's last action; if a restraint violation is detected, session can be rolled back + +## See Also + +- [Compaction Recovery](./compaction-recovery.md) — How Coordinator recovers when context is compacted +- [Result Persistence](./result-persistence.md) — How Coordinator preserves agent results before context expires diff --git a/docs/src/content/docs/reference/result-persistence.md b/docs/src/content/docs/reference/result-persistence.md new file mode 100644 index 000000000..df4bd8da0 --- /dev/null +++ b/docs/src/content/docs/reference/result-persistence.md @@ -0,0 +1,91 @@ +# Result Persistence + +> Mandatory archival of agent results before context expires. + +Agent results from `read_agent` expire within 2–3 minutes. The Scribe writes agent results to the orchestration log immediately after reading them to ensure results are not lost if the session ends or context is compacted. + +## Why Result Persistence Matters + +When an agent completes work: +1. You call `read_agent` to retrieve its results +2. Coordinator displays output to you +3. Session ends or context is compacted +4. The `read_agent` response disappears from memory +5. **Results are gone** unless persisted + +Result persistence ensures the Coordinator archives every agent's work to disk before moving forward, so you can always look up what happened. + +## Result Persistence Flow + +**Step 1: Immediate Write After `read_agent`** +After calling `read_agent`, before any other processing, the Scribe writes: +``` +.squad/orchestration-log/{ISO8601-timestamp}-{agent-name}.md +``` + +Example filename: `2026-04-08T14-23-45Z-API-Agent.md` (hyphens instead of colons for Windows compatibility) + +**Step 2: Log File Format** +Each log file contains: +- Agent name and spawn ID +- ISO 8601 timestamp of completion +- Original task description +- Result summary (what the agent did) +- List of files modified (if any) +- Full response text + +Example: +```markdown +# Orchestration Log: 2026-04-08T14-23-45Z — API Agent + +**Agent:** API Agent (spawn-id: api-agent-2026-04-08-142345) +**Task:** Refactor user endpoints to use dependency injection + +**Status:** ✅ Complete + +**Summary:** Refactored three endpoints (GET /users, POST /users, DELETE /users/:id) to accept dependency-injected logger. Added tests for each endpoint. All tests pass. + +**Files Modified:** +- src/routes/users.ts +- src/routes/__tests__/users.test.ts + +**Full Response:** +[Agent's complete output from read_agent] +``` + +**Step 3: Persistence Before Anything Else** +Result persistence happens BEFORE: +- Coordinator displays results to user +- Coordinator spawns follow-up agents +- Coordinator processes session state +- Any other action + +This ensures results are safe even if the session crashes or context compacts mid-process. + +## Observable Behavior + +- `.squad/orchestration-log/` contains timestamped markdown files for every agent that ran +- Each file includes agent name, task, result summary, and files modified +- If `read_agent` returns no response, Coordinator checks the filesystem for `history.md`, `decisions/`, and `output/` files written by the agent directly during its run +- Session never loses agent results + +## Orchestration Log Location + +``` +.squad/orchestration-log/ +├── 2026-04-08T14-23-45Z-API-Agent.md +├── 2026-04-08T14-35-12Z-Frontend-Agent.md +└── 2026-04-08T14-50-00Z-Scribe.md +``` + +File naming: `{ISO8601-timestamp}-{agent-name}.md` (timestamps use hyphens instead of colons for Windows compatibility) + +## Related Concepts + +- **Compaction Recovery** — Session state checkpoint that helps Coordinator resume after context is compacted +- **Session State** — Lightweight checkpoint (`.squad/session-state.md`) that captures next action needed + +## See Also + +- [Compaction Recovery](./compaction-recovery.md) — How Coordinator recovers from context compaction +- [Coordinator Restraint Rules](./coordinator-restraint.md) — How Coordinator avoids over-managing agents diff --git a/docs/src/content/docs/reference/sdk.md b/docs/src/content/docs/reference/sdk.md index abe6edf39..6b6b5547b 100644 --- a/docs/src/content/docs/reference/sdk.md +++ b/docs/src/content/docs/reference/sdk.md @@ -4,7 +4,7 @@ Complete reference for `@bradygaster/squad-sdk` — the programmatic API for Squad. -> **See also:** [API Reference (TypeDoc)](/reference/api/) — Complete auto-generated reference with full type signatures for all exports. +> **See also:** [API Reference](api-reference.md) — Complete auto-generated reference with full type signatures for all exports. ```bash npm install @bradygaster/squad-sdk diff --git a/docs/src/navigation.ts b/docs/src/navigation.ts index 62ce241a5..fc85d2b88 100644 --- a/docs/src/navigation.ts +++ b/docs/src/navigation.ts @@ -84,7 +84,7 @@ export const NAV_SECTIONS: NavSection[] = [ items: [ { title: 'CLI', slug: 'reference/cli' }, { title: 'SDK', slug: 'reference/sdk' }, - { title: 'SDK API Reference', slug: 'reference/api' }, + { title: 'SDK API Reference', slug: 'reference/api-reference' }, { title: 'SDK Integration', slug: 'reference/integration' }, { title: 'Tools & Hooks', slug: 'reference/tools-and-hooks' }, { title: 'Config', slug: 'reference/config' }, diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index d324da597..3ccb592e9 100644 --- a/docs/src/pages/index.astro +++ b/docs/src/pages/index.astro @@ -6,7 +6,7 @@ import Footer from '../components/Footer.astro'; const base = import.meta.env.BASE_URL; --- - +
@@ -32,13 +32,11 @@ const base = import.meta.env.BASE_URL;

- Your AI
- Development Team + Your agents bring your ideas to life

- Describe what you're building. Get a team of specialists that live in your repo. - Agents fan out, build in parallel, and land pull requests in minutes. + Describe what you're building. Squad coordinates agents that live in your repo and grow with your code. You spend less time prompting, more time deciding, and convert ideas into product more efficiently than ever before.

@@ -76,73 +74,81 @@ const base = import.meta.env.BASE_URL;
-
- -
-
-
-
-
-
-
-
- Copilot Chat -
-
-
-

YOU

-

Team, build the recipe listing page. We need an API endpoint that returns recipes and a React component that displays them.

+
+
+ +
+
+
+
+
+
+
+
+ Copilot Chat
-
-

SQUAD RESPONSE

-
-
- 🏗️ -
- Flight - — reviewing requirements, defining API contract +
+
+

YOU

+

Team, build the recipe listing page. We need an API endpoint that returns recipes and a React component that displays them.

+
+
+

SQUAD RESPONSE

+
+
+ 🏗️ +
+ Flight + — reviewing requirements, defining API contract +
-
-
- ⚛️ -
- EECOM - — building RecipeList component +
+ ⚛️ +
+ EECOM + — building RecipeList component +
-
-
- 🔧 -
- CAPCOM - — creating GET /api/recipes endpoint +
+ 🔧 +
+ CAPCOM + — creating GET /api/recipes endpoint +
-
-
- 🧪 -
- FIDO - — writing test cases from requirements +
+ 🧪 +
+ FIDO + — writing test cases from requirements +
-
-
- 📋 -
- Scribe - — logging decisions +
+ 📋 +
+ Scribe + — logging decisions +
-
-
-
-
-
-
+
+
+
+
+
+
+ 5 agents working in parallel…
- 5 agents working in parallel…
+ +
+

+ Responsible AI, explicitly: Squad democratizes multi-agentic work and extends human development teams' capabilities. Its goal is to make an individual more productive while enabling human oversight, review, and accountability at agentic speed. +

+
@@ -154,15 +160,15 @@ const base = import.meta.env.BASE_URL;
- Persistent team memory + Humans stay in charge
- Parallel execution + Built-in governance
- Setup in seconds + Persistent team memory
@@ -177,9 +183,9 @@ const base = import.meta.env.BASE_URL;

Why Squad?

-

Multi-agent orchestration, moved from prompts into code

+

Multi-agent orchestration, with humans in charge

- Enforced rules, persistent teams, parallel execution — everything you need for AI that actually ships. + Enforced rules, persistent teams, and accountable workflows — everything you need for AI that helps people ship responsibly.

@@ -188,8 +194,8 @@ const base = import.meta.env.BASE_URL;
🚀
-

Parallel AI Teamwork

-

Give a task to the team and multiple agents fan out simultaneously. No waiting in line — results land in minutes.

+

Human-Directed Parallel Work

+

Give a task to the team and specialists fan out together. You set the direction, review the results, and keep the final decision. Squad uses AI-emulated specialist roles to make a real human more productive, not to replace real humans.

@@ -197,13 +203,13 @@ const base = import.meta.env.BASE_URL;
🧠

Persistent Memory

-

Agents remember decisions and conventions across sessions. Your team gets smarter every time you work together.

+

Agents remember decisions and conventions across sessions so people spend less time repeating context and more time reviewing the work that matters.

🐙

GitHub-Native

-

Squad lives in your repo as .squad/. Commit it, share it, clone it. Your team travels with your code.

+

Squad lives in your repo as .squad/. Commit it, share it, clone it. Team state, decisions, and context stay inspectable with your code.

@@ -211,7 +217,7 @@ const base = import.meta.env.BASE_URL;
🛡️

Built-in Governance

-

File-write guards, PII scrubbing, reviewer lockout — rules enforced in code, not suggestions in prompts.

+

File-write guards, PII scrubbing, reviewer lockout, and clear escalation points — rules enforced in code, not suggestions in prompts.

@@ -256,7 +262,7 @@ const base = import.meta.env.BASE_URL;
2

Initialize

-

Describe your project and Squad generates your team.

+

Describe your project and Squad generates a team you direct.

squad init
@@ -322,8 +328,8 @@ const base = import.meta.env.BASE_URL;
-

Ship faster with
your AI team

-

One install. One command. Your full team, ready to build.

+

Ship faster with
a human-led AI team

+

One install. One command. Keep people accountable while Squad handles the repetitive coordination.

{ await openSearchViaButton(page); const input = page.locator('#search-input'); // Use a single random character that pagefind won't match - await input.fill('ÿ'); + await input.fill('├┐'); // Wait for search debounce, then check for either "No results" or // the default "Start typing" (pagefind may not index this character). @@ -157,8 +157,74 @@ test.describe('Search results', () => { await page.waitForTimeout(1500); const resultsContainer = page.locator('#search-results'); const text = await resultsContainer.textContent(); - // Either we get "No results" or actual results — either way the + // Either we get "No results" or actual results ΓÇö either way the // search pipeline processed the query (not still on "Start typing") - expect(text.trim()).not.toBe('Start typing to search…'); + expect(text.trim()).not.toBe('Start typing to searchΓǪ'); + }); +}); + +test.describe('Search after View Transition navigation', () => { + + // Helper: search, click first result, wait for navigation to complete + async function searchAndNavigate(page) { + await page.goto(BASE); + const searchBtn = page.locator('#search-btn'); + await expect(searchBtn).toBeVisible(); + await searchBtn.click(); + + const modal = page.locator('#search-modal'); + await expect(modal).not.toHaveClass(/hidden/); + + const input = page.locator('#search-input'); + await input.fill('agent'); + + const resultLinks = page.locator('#search-results a'); + await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); + + const href = await resultLinks.first().getAttribute('href'); + expect(href).toBeTruthy(); + await resultLinks.first().click(); + + // Wait for View Transition navigation to complete + await page.waitForURL(new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: 10_000 }); + // Allow astro:page-load handlers to re-initialize + await page.waitForTimeout(500); + return href; + } + + test('search modal works after navigating via search result (View Transition)', async ({ page }) => { + await searchAndNavigate(page); + + // On the new page, try to open search again + const searchBtn = page.locator('#search-btn'); + await expect(searchBtn).toBeVisible({ timeout: 5_000 }); + await searchBtn.click(); + + const modal = page.locator('#search-modal'); + await expect(modal).not.toHaveClass(/hidden/); + + // Verify input is focused and can receive text + const input = page.locator('#search-input'); + await expect(input).toBeFocused({ timeout: 3_000 }); + + // Type a new query and verify results appear + await input.fill('charter'); + const resultLinks = page.locator('#search-results a'); + await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); + const count = await resultLinks.count(); + expect(count).toBeGreaterThan(0); + }); + + test('Ctrl+K opens search after View Transition navigation', async ({ page }) => { + await searchAndNavigate(page); + + // Press Ctrl+K on the post-navigation page + await page.keyboard.press('Control+k'); + + const modal = page.locator('#search-modal'); + await expect(modal).not.toHaveClass(/hidden/); + + const input = page.locator('#search-input'); + await expect(input).toBeFocused({ timeout: 3_000 }); }); }); diff --git a/docs/tests/sidebar.spec.mjs b/docs/tests/sidebar.spec.mjs new file mode 100644 index 000000000..77917c9ee --- /dev/null +++ b/docs/tests/sidebar.spec.mjs @@ -0,0 +1,70 @@ +/** + * Playwright e2e tests for docs sidebar behavior. + */ +import { test, expect } from '@playwright/test'; + +const START = '/squad/docs/get-started/five-minute-start/'; +const TARGET = '/squad/docs/scenarios/ci-cd-integration/'; + +test.describe('Docs sidebar navigation', () => { + test('keeps the sidebar scroll position when opening another article', async ({ page }) => { + await page.goto(START); + + await page.evaluate(() => sessionStorage.removeItem('squad-docs-sidebar-scroll')); + + const sidebar = page.locator('#sidebar'); + await expect(sidebar).toBeVisible(); + + await page.evaluate(() => { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) { + throw new Error('Sidebar not found'); + } + + sidebar.scrollTop = 900; + sidebar.dispatchEvent(new Event('scroll')); + window.scrollTo(0, 1000); + }); + + const initialSidebarScroll = await page.evaluate(() => { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) { + throw new Error('Sidebar not found'); + } + + return sidebar.scrollTop; + }); + + expect(initialSidebarScroll).toBeGreaterThan(0); + + await page.evaluate((targetHref) => { + const link = document.querySelector(`#sidebar a[href="${targetHref}"]`) + ?? document.querySelector('#sidebar a[href*="docs/scenarios/ci-cd-integration/"]'); + + if (!(link instanceof HTMLAnchorElement)) { + throw new Error('Target sidebar link not found'); + } + + link.click(); + }, TARGET); + + await page.waitForURL(new RegExp(TARGET.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + + await expect.poll(async () => page.evaluate(() => { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) throw new Error('Sidebar not found'); + return sidebar.scrollTop; + }), { timeout: 5000 }).toBeGreaterThan(initialSidebarScroll - 10); + + const restoredSidebarScroll = await page.evaluate(() => { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) throw new Error('Sidebar not found'); + return sidebar.scrollTop; + }); + + const pageScroll = await page.evaluate(() => window.scrollY); + + expect(pageScroll).toBeLessThan(20); + expect(Math.abs(restoredSidebarScroll - initialSidebarScroll)).toBeLessThan(10); + }); +}); diff --git a/index.cjs b/index.cjs index 2a23b7ff0..fbdbafcb3 100644 --- a/index.cjs +++ b/index.cjs @@ -1180,11 +1180,35 @@ if (cmd === 'import') { fs.writeFileSync(path.join(agentDir, 'history.md'), historyContent); } - // Write skills - for (const skillContent of manifest.skills) { + // Write skills to .copilot/skills/ (the canonical location) + const copilotSkillsImportDir = path.join(dest, '.copilot', 'skills'); + const importedSkills = manifest.skills.map((skillContent, index) => { const nameMatch = skillContent.match(/^name:\s*["']?(.+?)["']?\s*$/m); - const skillName = nameMatch ? nameMatch[1].trim().toLowerCase().replace(/\s+/g, '-') : `skill-${manifest.skills.indexOf(skillContent)}`; - const skillDir = path.join(aiTeamDir, 'skills', skillName); + const skillName = nameMatch + ? nameMatch[1].trim().toLowerCase().replace(/\s+/g, '-') + : `skill-${index}`; + return { skillContent, skillName }; + }); + const hasForce = typeof force !== 'undefined' && force; + + if (fs.existsSync(copilotSkillsImportDir)) { + if (hasForce) { + const archivedSkillsDir = path.join(dest, '.copilot', `skills.backup.${Date.now()}`); + fs.renameSync(copilotSkillsImportDir, archivedSkillsDir); + } else { + const collidingSkills = importedSkills + .map(({ skillName }) => skillName) + .filter((skillName) => fs.existsSync(path.join(copilotSkillsImportDir, skillName))); + + if (collidingSkills.length > 0) { + fatal(`Import would overwrite existing Copilot skills: ${collidingSkills.join(', ')}. Re-run with --force to replace the existing .copilot/skills directory.`); + } + } + } + + fs.mkdirSync(copilotSkillsImportDir, { recursive: true }); + for (const { skillContent, skillName } of importedSkills) { + const skillDir = path.join(copilotSkillsImportDir, skillName); fs.mkdirSync(skillDir, { recursive: true }); fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); } diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 345ddde95..c30760f8a 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) ${YELLOW}[DEPRECATED]${RESET}`); + console.log(` ${BOLD}(default)${RESET} Launch interactive shell (no args)`); console.log(` Flags: --global (init in personal squad directory)`); console.log(` ${BOLD}init${RESET} Initialize Squad (markdown-only, default)`); console.log(` Flags: --sdk (SDK builder syntax)`); @@ -203,20 +203,18 @@ 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`); 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 ${YELLOW}[DEPRECATED]${RESET}`); + console.log(` ${BOLD}start${RESET} Start Copilot with remote access from phone/browser`); console.log(` Usage: start [--tunnel] [--port ] [--command ]`); console.log(` [copilot flags...]`); - console.log(` ${DIM}⚠ Deprecated: will be removed in a future release.${RESET}`); + console.log(` Examples: start --tunnel --yolo`); + console.log(` start --tunnel --model claude-sonnet-4`); + console.log(` start --tunnel --command "gh copilot"`); console.log(` ${BOLD}nap${RESET} Context hygiene (compress, prune, archive .squad/ state)`); console.log(` Usage: nap [--deep] [--dry-run]`); console.log(` Flags: --deep (thorough cleanup), --dry-run (preview only)`); @@ -241,12 +239,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) ${YELLOW}[DEPRECATED]${RESET}`); + console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot)`); console.log(` Usage: rc [--tunnel] [--port ] [--path ]`); console.log(` ${BOLD}copilot-bridge${RESET} Check Copilot ACP stdio compatibility`); console.log(` ${BOLD}init-remote${RESET} Link project to remote team root (shorthand)`); console.log(` Usage: init-remote `); - console.log(` ${BOLD}rc-tunnel${RESET} Check devtunnel CLI availability ${YELLOW}[DEPRECATED]${RESET}`); + console.log(` ${BOLD}rc-tunnel${RESET} Check devtunnel CLI availability`); console.log(` ${BOLD}discover${RESET} List known squads and their capabilities`); console.log(` ${BOLD}delegate${RESET} Create work in another squad`); console.log(` Usage: delegate `); @@ -275,8 +273,6 @@ 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(); @@ -431,6 +427,45 @@ async function main(): Promise { ? args[authUserIdx + 1] : undefined; + // --notify-level runtime validation + const notifyLevelIdx = args.indexOf('--notify-level'); + const rawNotifyLevel = (notifyLevelIdx !== -1 && args[notifyLevelIdx + 1]) + ? args[notifyLevelIdx + 1] + : undefined; + const validNotifyLevels = ['all', 'important', 'none'] as const; + const notifyLevel = rawNotifyLevel && (validNotifyLevels as readonly string[]).includes(rawNotifyLevel) + ? rawNotifyLevel as typeof validNotifyLevels[number] + : rawNotifyLevel + ? (console.error(`\u26a0\ufe0f Invalid --notify-level "${rawNotifyLevel}". Valid: all, important, none.`), undefined) + : undefined; + + const overnightStartIdx = args.indexOf('--overnight-start'); + const overnightStart = (overnightStartIdx !== -1 && args[overnightStartIdx + 1]) + ? args[overnightStartIdx + 1] + : undefined; + + const overnightEndIdx = args.indexOf('--overnight-end'); + const overnightEnd = (overnightEndIdx !== -1 && args[overnightEndIdx + 1]) + ? args[overnightEndIdx + 1] + : undefined; + + const sentinelFileIdx = args.indexOf('--sentinel-file'); + const sentinelFile = (sentinelFileIdx !== -1 && args[sentinelFileIdx + 1]) + ? args[sentinelFileIdx + 1] + : undefined; + + // --state-backend runtime validation: reject invalid values upfront + const stateBackendIdx = args.indexOf('--state-backend'); + const rawStateBackend = (stateBackendIdx !== -1 && args[stateBackendIdx + 1]) + ? args[stateBackendIdx + 1] + : undefined; + const validBackends = ['worktree', 'git-notes', 'orphan', 'external'] as const; + if (rawStateBackend && !(validBackends as readonly string[]).includes(rawStateBackend)) { + console.error(`\u26a0\ufe0f Invalid --state-backend "${rawStateBackend}". Valid: ${validBackends.join(', ')}.`); + process.exit(1); + } + const stateBackend = rawStateBackend as typeof validBackends[number] | undefined; + // Build capability overrides from CLI flags and --no-{cap} flags const capabilities: Record> = {}; const registry = createDefaultRegistry(); @@ -460,6 +495,11 @@ async function main(): Promise { dispatchMode, logFile, authUser, + notifyLevel, + overnightStart, + overnightEnd, + sentinelFile, + stateBackend, capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined, }); @@ -467,6 +507,7 @@ async function main(): Promise { // Skip values that follow known value-flags (e.g. "--interval 5" → "5" is not positional). const knownValueFlags = new Set([ '--interval', '--copilot-flags', '--agent-cmd', '--max-concurrent', '--timeout', '--board-project', '--auth-user', + '--dispatch-mode', '--log-file', '--notify-level', '--overnight-start', '--overnight-end', '--sentinel-file', '--state-backend', ]); const watchArgStart = args.indexOf(cmd) + 1; const watchArgs = args.slice(watchArgStart); @@ -620,12 +661,6 @@ 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'); @@ -719,12 +754,9 @@ 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) { - 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 @@ -805,7 +837,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'); @@ -840,7 +872,6 @@ 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`); diff --git a/packages/squad-cli/src/cli/commands/cast.ts b/packages/squad-cli/src/cli/commands/cast.ts index 34cd4c134..778d69144 100644 --- a/packages/squad-cli/src/cli/commands/cast.ts +++ b/packages/squad-cli/src/cli/commands/cast.ts @@ -7,6 +7,7 @@ * @module cli/commands/cast */ +import * as path from 'node:path'; import { LocalAgentSource } from '@bradygaster/squad-sdk/config/agent-source'; import { resolvePersonalAgents, mergeSessionCast } from '@bradygaster/squad-sdk/agents/personal'; import { resolveSquadPaths } from '@bradygaster/squad-sdk/resolution'; @@ -23,8 +24,15 @@ export async function runCast(cwd: string): Promise { fatal('No squad found. Run "squad init" first.'); } - // Discover project agents - const projectSource = new LocalAgentSource(paths.teamDir); + // Discover project agents. + // LocalAgentSource appends .squad/agents to its base path, so we must supply: + // - local mode: parent of paths.projectDir (the repo root) + // - remote mode: paths.teamDir (the team repo root, which itself contains .squad/agents) + const agentBase = + paths.mode === 'remote' + ? paths.teamDir + : path.resolve(paths.projectDir, '..'); + const projectSource = new LocalAgentSource(agentBase); const projectAgents = await projectSource.listAgents(); // Discover personal agents diff --git a/packages/squad-cli/src/cli/commands/externalize.ts b/packages/squad-cli/src/cli/commands/externalize.ts index df2dc3ca9..ceb23b68f 100644 --- a/packages/squad-cli/src/cli/commands/externalize.ts +++ b/packages/squad-cli/src/cli/commands/externalize.ts @@ -20,17 +20,24 @@ import { fatal } from '../core/errors.js'; const storage = new FSStorageProvider(); -/** Directories under .squad/ that hold the actual state */ -const STATE_DIRS = [ - 'agents', 'casting', 'decisions', 'identity', 'orchestration-log', - 'log', 'plugins', 'templates', 'skills', 'sessions', '.scratch', -]; - -/** Files under .squad/ that hold state (not config.json — that stays) */ -const STATE_FILES = [ - 'team.md', 'routing.md', 'ceremonies.md', 'decisions.md', - 'decisions-archive.md', '.first-run', -]; +/** Entries under .squad/ that must NOT be externalized (they stay in the repo). + * All of these are read from the working tree by runtime code that does not + * go through external-state resolution (detectSquadDir → local .squad/). + * - config.json: thin marker read by the walk-up resolver + * - manifest.json: public contract read by cross-squad discovery + * - workstreams.json: workstream config read by streams resolver + * - upstream.json: upstream registry read by cross-squad discover/delegate + * - squad-registry.json: squad registry read by cross-squad discovery + * - _upstream_repos: git clone cache read locally by upstream/cross-squad resolvers + */ +const KEEP_LOCAL = new Set([ + 'config.json', + 'manifest.json', + 'workstreams.json', + 'upstream.json', + 'squad-registry.json', + '_upstream_repos', +]); /** * Move .squad/ state to the external directory. @@ -52,29 +59,24 @@ export function runExternalize(projectDir: string, projectKey?: string): void { let movedCount = 0; - // Move directories - for (const dir of STATE_DIRS) { - const src = path.join(squadDir, dir); - if (!storage.existsSync(src)) continue; - const dest = path.join(externalDir, dir); - // Copy contents into the external state directory. If destination files - // already exist, they may be overwritten by copyDirRecursive(). - copyDirRecursive(src, dest); - storage.deleteDirSync?.(src); - movedCount++; - } - - // Move files - for (const file of STATE_FILES) { - const src = path.join(squadDir, file); - if (!storage.existsSync(src)) continue; - const dest = path.join(externalDir, file); - const content = storage.readSync(src); - if (content != null) { - storage.mkdirSync(path.dirname(dest), { recursive: true }); - storage.writeSync(dest, content); + // Dynamically discover everything in .squad/ — move it all except KEEP_LOCAL. + // This eliminates silent orphaning when new state artifacts are added. + const entries = storage.listSync?.(squadDir) ?? []; + for (const entry of entries) { + if (KEEP_LOCAL.has(entry)) continue; + const src = path.join(squadDir, entry); + const dest = path.join(externalDir, entry); + if (storage.isDirectorySync(src)) { + copyDirRecursive(src, dest); + storage.deleteDirSync?.(src); + } else { + const content = storage.readSync(src); + if (content != null) { + storage.mkdirSync(path.dirname(dest), { recursive: true }); + storage.writeSync(dest, content); + } + storage.deleteSync?.(src); } - storage.deleteSync?.(src); movedCount++; } @@ -147,22 +149,19 @@ export function runInternalize(projectDir: string): void { let movedCount = 0; - // Copy directories back - for (const dir of STATE_DIRS) { - const src = path.join(externalDir, dir); - if (!storage.existsSync(src)) continue; - const dest = path.join(squadDir, dir); - copyDirRecursive(src, dest); - movedCount++; - } - - // Copy files back - for (const file of STATE_FILES) { - const src = path.join(externalDir, file); - if (!storage.existsSync(src)) continue; - const content = storage.readSync(src); - if (content != null) { - storage.writeSync(path.join(squadDir, file), content); + // Dynamically discover everything in the external dir and copy it back. + const entries = storage.listSync?.(externalDir) ?? []; + for (const entry of entries) { + if (KEEP_LOCAL.has(entry)) continue; + const src = path.join(externalDir, entry); + const dest = path.join(squadDir, entry); + if (storage.isDirectorySync(src)) { + copyDirRecursive(src, dest); + } else { + const content = storage.readSync(src); + if (content != null) { + storage.writeSync(dest, content); + } } movedCount++; } diff --git a/packages/squad-cli/src/cli/commands/loop.ts b/packages/squad-cli/src/cli/commands/loop.ts index 23fd71d8f..46c36d433 100644 --- a/packages/squad-cli/src/cli/commands/loop.ts +++ b/packages/squad-cli/src/cli/commands/loop.ts @@ -136,13 +136,13 @@ function buildLoopAgentCommand( ): { cmd: string; args: string[] } { if (options.agentCmd) { const parts = options.agentCmd.trim().split(/\s+/); - return { cmd: parts[0]!, args: [...parts.slice(1), '--message', prompt] }; + return { cmd: parts[0]!, args: [...parts.slice(1), '-p', prompt] }; } - const args = ['copilot', '--message', prompt]; + const args = ['-p', prompt]; if (options.copilotFlags) { args.push(...options.copilotFlags.trim().split(/\s+/)); } - return { cmd: 'gh', args }; + return { cmd: 'copilot', args }; } // ── Capability Phase Runner ────────────────────────────────────── @@ -244,10 +244,10 @@ function createNoopAdapter(): ReturnType { // ── gh Copilot Preflight ───────────────────────────────────────── -/** Verify `gh` CLI with copilot extension is available. */ -async function checkGhCopilot(): Promise { +/** Verify the copilot CLI is available. */ +async function checkCopilotCli(): Promise { return new Promise((resolve, reject) => { - execFile('gh', ['copilot', '--version'], (err) => { + execFile('copilot', ['--version'], (err) => { if (err) reject(err); else resolve(); }); @@ -313,12 +313,12 @@ export async function runLoop(dest: string, options: LoopConfig): Promise fatal('timeout must be a positive number of minutes'); } - // Preflight: verify gh copilot is available (skip if user overrides the agent command) + // Preflight: verify copilot CLI is available (skip if user overrides the agent command) if (!options.agentCmd) { try { - await checkGhCopilot(); + await checkCopilotCli(); } catch { - fatal('gh CLI with copilot extension required. Install from https://cli.github.com/ and run `gh extension install github/gh-copilot`'); + fatal('Copilot CLI required. Install from https://cli.github.com/ and run `gh extension install github/gh-copilot`'); } } 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/start.ts b/packages/squad-cli/src/cli/commands/start.ts index e6a96876c..b31de6f66 100644 --- a/packages/squad-cli/src/cli/commands/start.ts +++ b/packages/squad-cli/src/cli/commands/start.ts @@ -30,6 +30,8 @@ const RESET = '\x1b[0m'; const DIM = '\x1b[2m'; const GREEN = '\x1b[32m'; const YELLOW = '\x1b[33m'; +const MISSING_MODULE_RE = + /\b(?:MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND)\b|Cannot find module|Cannot find package/i; export interface StartOptions { tunnel: boolean; @@ -38,7 +40,35 @@ export interface StartOptions { command?: string; } +async function checkNodePty(): Promise { + try { + return await import('node-pty'); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + if (MISSING_MODULE_RE.test(detail)) { + throw new Error('node-pty not available. Install it for PTY support:'); + } + throw new Error('node-pty not available. Install it for PTY support:', { + cause: detail, + }); + } +} + export async function runStart(cwd: string, options: StartOptions): Promise { + // ─── Verify node-pty availability FIRST (before any side effects) ─── + let nodePty: any; + try { + nodePty = await checkNodePty(); + } catch (err) { + const detail = err instanceof Error && typeof err.cause === 'string' ? err.cause : undefined; + console.error(`${YELLOW}\u2717${RESET} ${(err as Error).message}`); + console.error(` ${DIM}npm install -g node-pty${RESET}`); + if (detail) { + console.error(`\n${DIM}Error: ${detail}${RESET}`); + } + process.exit(1); + } + const { repo, branch } = getGitInfo(cwd); const machine = getMachineId(); const squadDir = storage.existsSync(path.join(cwd, '.squad')) @@ -47,7 +77,7 @@ export async function runStart(cwd: string, options: StartOptions): Promise { console.log(code); }); } catch {} @@ -120,8 +150,6 @@ export async function runStart(cwd: string, options: StartOptions): Promise { try { - await execFileAsync('gh', ['project', '--help'], shellOpt); + await execFileAsync('gh', ['project', '--help']); return { ok: true }; } catch { return { ok: false, reason: 'gh project CLI not available or not authenticated' }; @@ -39,7 +35,7 @@ export class BoardCapability implements WatchCapability { '--owner', '@me', '--format', 'json', '--limit', '300', - ], { maxBuffer: 10 * 1024 * 1024, ...shellOpt }); + ], { maxBuffer: 10 * 1024 * 1024 }); const items = JSON.parse(itemsJson) as { items?: Array<{ @@ -78,7 +74,7 @@ export class BoardCapability implements WatchCapability { 'gh', ['issue', 'close', String(item.content!.number!), '--comment', '🤖 Ralph: Auto-closing — issue has been in Done for >3 days.'], - { maxBuffer: 5 * 1024 * 1024, ...shellOpt }, + { maxBuffer: 5 * 1024 * 1024 }, (err) => (err ? reject(err) : resolve()), ); }); @@ -113,7 +109,7 @@ export async function updateBoardStatus( let repoUrl: string; try { const repoName = execFileSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { - encoding: 'utf-8', timeout: 10_000, ...shellOpt, + encoding: 'utf-8', timeout: 10_000, }).trim(); repoUrl = `https://github.com/${repoName}/issues/${issueNumber}`; } catch { @@ -124,7 +120,7 @@ export async function updateBoardStatus( 'project', 'item-add', String(projectNum), '--owner', options.owner ?? '@me', '--url', repoUrl, - ], { maxBuffer: 5 * 1024 * 1024, ...shellOpt }); + ], { maxBuffer: 5 * 1024 * 1024 }); const statusMap: Record = { 'todo': 'Todo', 'in-progress': 'In Progress', 'done': 'Done', 'blocked': 'Blocked', @@ -136,7 +132,7 @@ export async function updateBoardStatus( '--owner', options.owner ?? '@me', '--format', 'json', '--limit', '300', - ], { maxBuffer: 10 * 1024 * 1024, ...shellOpt }); + ], { maxBuffer: 10 * 1024 * 1024 }); const items = JSON.parse(itemsJson) as { items?: Array<{ id: string; content?: { number?: number } }> }; const item = items.items?.find(i => i.content?.number === issueNumber); @@ -148,6 +144,6 @@ export async function updateBoardStatus( '--id', item.id, '--field-id', 'Status', '--single-select-option-id', statusValue, - ], { maxBuffer: 5 * 1024 * 1024, ...shellOpt }); + ], { maxBuffer: 5 * 1024 * 1024 }); } catch { /* best-effort */ } } diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts index ac98ccb9d..f47aefbd8 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts @@ -3,12 +3,35 @@ */ import path from 'node:path'; +import { execFile } from 'node:child_process'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { buildAgentCommand, spawnWithTimeout } from '../agent-spawn.js'; const storage = new FSStorageProvider(); +function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string; args: string[] } { + if (context.agentCmd) { + const parts = context.agentCmd.trim().split(/\s+/); + return { cmd: parts[0]!, args: [...parts.slice(1), '-p', prompt] }; + } + const args = ['-p', prompt]; + if (context.copilotFlags) args.push(...context.copilotFlags.trim().split(/\s+/)); + return { cmd: 'copilot', args }; +} + +function spawnWithTimeout(cmd: string, args: string[], cwd: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 }, (err) => { + if (err) { + const execErr = err as Error & { killed?: boolean }; + reject(new Error(execErr.killed ? `Timed out after ${Math.round(timeoutMs / 1000)}s` : execErr.message)); + } else { + resolve(); + } + }); + }); +} + export class DecisionHygieneCapability implements WatchCapability { readonly name = 'decision-hygiene'; readonly description = 'Auto-merge decision inbox when >5 files accumulate'; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts index cd6c4bfbc..e583926c8 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts @@ -2,13 +2,12 @@ * Execute capability — spawns Copilot sessions for eligible issues. */ -import { execFile } from 'node:child_process'; +import { execFile, type ChildProcess } from 'node:child_process'; import { existsSync } from 'node:fs'; import path from 'node:path'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; import type { MachineCapabilities } from '@bradygaster/squad-sdk/ralph/capabilities'; import { createVerboseLogger } from '../verbose.js'; -import { buildAgentCommand, spawnAgent, IS_WINDOWS } from '../agent-spawn.js'; /** Normalized work item for execution. */ export interface ExecutableWorkItem { @@ -46,6 +45,24 @@ export function classifyIssue(title: string): 'read' | 'write' { return 'write'; // default to write (safer — gets full agent session) } +/** Build agent command for a prompt. */ +function buildAgentCommand( + prompt: string, + context: WatchContext, +): { cmd: string; args: string[] } { + if (context.agentCmd) { + const parts = context.agentCmd.trim().split(/\s+/); + const cmd = parts[0]!; + const args = [...parts.slice(1), '-p', prompt]; + return { cmd, args }; + } + const args = ['-p', prompt]; + if (context.copilotFlags) { + args.push(...context.copilotFlags.trim().split(/\s+/)); + } + return { cmd: 'copilot', args }; +} + /** Labels that indicate an issue should not be auto-executed. */ const BLOCKING_LABELS = ['status:blocked', 'status:wontfix', 'status:on-hold', 'blocked']; @@ -134,7 +151,34 @@ async function executeAll( const prompt = buildAgentPrompt(issues, context.teamRoot); const { cmd, args } = buildAgentCommand(prompt, context); - return spawnAgent(cmd, args, context.teamRoot, timeoutMs); + return new Promise<{ success: boolean; error?: string }>((resolve) => { + const cp: ChildProcess = execFile( + cmd, + args, + { cwd: context.teamRoot, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 }, + (err) => { + if (err) { + const execErr = err as Error & { killed?: boolean }; + const msg = execErr.killed ? `Timed out` : execErr.message; + resolve({ success: false, error: msg }); + } else { + resolve({ success: true }); + } + }, + ); + + // Track child PID for cleanup on exit/crash + if (context.pidTracker && cp.pid) { + const issueNums = issues.map(i => `#${i.number}`).join(','); + context.pidTracker.track(cp.pid, `copilot-session-${issueNums}`); + } + + cp.on('exit', () => { + if (context.pidTracker && cp.pid) { + context.pidTracker.untrack(cp.pid); + } + }); + }); } export class ExecuteCapability implements WatchCapability { @@ -146,7 +190,7 @@ export class ExecuteCapability implements WatchCapability { async preflight(_context: WatchContext): Promise { return new Promise((resolve) => { - execFile('gh', ['--version'], { shell: IS_WINDOWS }, (err) => { + execFile('gh', ['--version'], (err) => { resolve(err ? { ok: false, reason: 'gh CLI not found' } : { ok: true }); }); }); @@ -160,9 +204,8 @@ export class ExecuteCapability implements WatchCapability { vlog.log(`Execute: agentCmd=${context.agentCmd ?? 'copilot'}, timeout=${timeout / 60_000}m`); - // Use shared round data when available (#923), otherwise fetch - const sdkItems = context.roundData?.issues - ?? await context.adapter.listWorkItems({ tags: ['squad'], state: 'open', limit: 50 }); + // Fetch open issues with squad label + const sdkItems = await context.adapter.listWorkItems({ tags: ['squad'], state: 'open', limit: 50 }); const issues: ExecutableWorkItem[] = sdkItems.map(wi => ({ number: wi.id, title: wi.title, diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/fleet-dispatch.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/fleet-dispatch.ts index 1472680d2..98ec3affc 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/fleet-dispatch.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/fleet-dispatch.ts @@ -8,14 +8,13 @@ * analysis tracks inside one Copilot invocation. */ -import { execFileSync } from 'node:child_process'; +import { execSync, execFileSync } from 'node:child_process'; import { writeFileSync, readFileSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; import type { MachineCapabilities } from '@bradygaster/squad-sdk/ralph/capabilities'; import type { DispatchMode } from '../config.js'; -import { IS_WINDOWS, resolveCopilotCmd } from '../agent-spawn.js'; import { type ExecutableWorkItem, findExecutableIssues, @@ -72,11 +71,9 @@ function invokeFleet( // Read the prompt from file const promptContent = readFileSync(promptFile, 'utf-8'); - // Use execFileSync with args array — no shell injection risk - // shell: IS_WINDOWS ensures PATH/.cmd resolution works on Windows - const { cmd, cmdPrefix } = resolveCopilotCmd(); - const result = execFileSync(cmd, [ - ...cmdPrefix, + // Use execFileSync with args array — no shell, no injection risk + const copilotBin = process.platform === 'win32' ? 'copilot.cmd' : 'copilot'; + const result = execFileSync(copilotBin, [ '-p', promptContent, '--allow-all', '--no-ask-user', @@ -85,7 +82,6 @@ function invokeFleet( cwd, timeout: timeoutMs, encoding: 'utf-8' as BufferEncoding, - shell: IS_WINDOWS, }); return { success: true, output: String(result) }; @@ -110,8 +106,7 @@ export class FleetDispatchCapability implements WatchCapability { async preflight(_context: WatchContext): Promise { // Fleet dispatch requires the copilot CLI — quick sanity check try { - const { cmd, cmdPrefix } = resolveCopilotCmd(); - execFileSync(cmd, [...cmdPrefix, '--version'], { encoding: 'utf-8', stdio: 'pipe', shell: IS_WINDOWS }); + execSync('copilot --version', { encoding: 'utf-8', stdio: 'pipe' }); return { ok: true }; } catch { return { ok: false, reason: 'copilot CLI not found — required for fleet dispatch' }; @@ -129,9 +124,8 @@ export class FleetDispatchCapability implements WatchCapability { const timeoutMs = ((context.config['timeout'] as number) ?? 30) * 60_000; - // Use shared round data when available (#923), otherwise fetch - const sdkItems = context.roundData?.issues - ?? await context.adapter.listWorkItems({ tags: ['squad'], state: 'open', limit: 50 }); + // Fetch the same issue set as the execute capability + const sdkItems = await context.adapter.listWorkItems({ tags: ['squad'], state: 'open', limit: 50 }); const issues: ExecutableWorkItem[] = sdkItems.map(wi => ({ number: wi.id, title: wi.title, diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts index a9b56a270..14f5d9c73 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-email.ts @@ -2,8 +2,31 @@ * MonitorEmail capability — scan email for actionable items + GitHub alerts. */ +import { execFile } from 'node:child_process'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { buildAgentCommand, spawnWithTimeout } from '../agent-spawn.js'; + +function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string; args: string[] } { + if (context.agentCmd) { + const parts = context.agentCmd.trim().split(/\s+/); + return { cmd: parts[0]!, args: [...parts.slice(1), '-p', prompt] }; + } + const args = ['-p', prompt]; + if (context.copilotFlags) args.push(...context.copilotFlags.trim().split(/\s+/)); + return { cmd: 'copilot', args }; +} + +function spawnWithTimeout(cmd: string, args: string[], cwd: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 }, (err) => { + if (err) { + const execErr = err as Error & { killed?: boolean }; + reject(new Error(execErr.killed ? `Timed out after ${Math.round(timeoutMs / 1000)}s` : execErr.message)); + } else { + resolve(); + } + }); + }); +} export class MonitorEmailCapability implements WatchCapability { readonly name = 'monitor-email'; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts index 24b95c97e..e5162557a 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/monitor-teams.ts @@ -2,8 +2,32 @@ * MonitorTeams capability — scan Teams for actionable messages via WorkIQ. */ +import { execFile } from 'node:child_process'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { buildAgentCommand, spawnWithTimeout } from '../agent-spawn.js'; + +/** Build agent command from prompt, respecting --agent-cmd. */ +function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string; args: string[] } { + if (context.agentCmd) { + const parts = context.agentCmd.trim().split(/\s+/); + return { cmd: parts[0]!, args: [...parts.slice(1), '-p', prompt] }; + } + const args = ['-p', prompt]; + if (context.copilotFlags) args.push(...context.copilotFlags.trim().split(/\s+/)); + return { cmd: 'copilot', args }; +} + +function spawnWithTimeout(cmd: string, args: string[], cwd: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 }, (err) => { + if (err) { + const execErr = err as Error & { killed?: boolean }; + reject(new Error(execErr.killed ? `Timed out after ${Math.round(timeoutMs / 1000)}s` : execErr.message)); + } else { + resolve(); + } + }); + }); +} export class MonitorTeamsCapability implements WatchCapability { readonly name = 'monitor-teams'; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts index 72e04a463..85be72b41 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts @@ -3,12 +3,35 @@ */ import path from 'node:path'; +import { execFile } from 'node:child_process'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { buildAgentCommand, spawnWithTimeout } from '../agent-spawn.js'; const storage = new FSStorageProvider(); +function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string; args: string[] } { + if (context.agentCmd) { + const parts = context.agentCmd.trim().split(/\s+/); + return { cmd: parts[0]!, args: [...parts.slice(1), '-p', prompt] }; + } + const args = ['-p', prompt]; + if (context.copilotFlags) args.push(...context.copilotFlags.trim().split(/\s+/)); + return { cmd: 'copilot', args }; +} + +function spawnWithTimeout(cmd: string, args: string[], cwd: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 }, (err) => { + if (err) { + const execErr = err as Error & { killed?: boolean }; + reject(new Error(execErr.killed ? `Timed out after ${Math.round(timeoutMs / 1000)}s` : execErr.message)); + } else { + resolve(); + } + }); + }); +} + export class RetroCapability implements WatchCapability { readonly name = 'retro'; readonly description = 'Enforce retrospective checks (Fridays or when missed >7 days)'; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/self-pull.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/self-pull.ts index 1e043320c..2a64cf6df 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/self-pull.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/self-pull.ts @@ -7,7 +7,6 @@ import { execFile, execFileSync } from 'node:child_process'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { IS_WINDOWS } from '../agent-spawn.js'; export class SelfPullCapability implements WatchCapability { readonly name = 'self-pull'; @@ -18,7 +17,7 @@ export class SelfPullCapability implements WatchCapability { async preflight(_context: WatchContext): Promise { return new Promise((resolve) => { - execFile('git', ['--version'], { shell: IS_WINDOWS }, (err) => { + execFile('git', ['--version'], (err) => { resolve(err ? { ok: false, reason: 'git not found' } : { ok: true }); }); }); @@ -30,27 +29,27 @@ export class SelfPullCapability implements WatchCapability { // Capture HEAD before pull for change detection let headBefore = ''; try { - headBefore = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8', shell: IS_WINDOWS }).trim(); + headBefore = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' }).trim(); } catch { /* non-fatal */ } // Stash if there are local changes let didStash = false; try { - const status = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8', shell: IS_WINDOWS }).trim(); + const status = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' }).trim(); if (status.length > 0) { - execFileSync('git', ['stash', '--include-untracked'], { cwd, encoding: 'utf-8', shell: IS_WINDOWS }); + execFileSync('git', ['stash', '--include-untracked'], { cwd, encoding: 'utf-8' }); didStash = true; } } catch { /* non-fatal — proceed without stash */ } // Fetch + pull await new Promise((resolve, reject) => { - execFile('git', ['fetch', '--quiet'], { cwd, shell: IS_WINDOWS }, (err) => + execFile('git', ['fetch', '--quiet'], { cwd }, (err) => err ? reject(err) : resolve(), ); }); await new Promise((resolve, reject) => { - execFile('git', ['pull', '--ff-only', '--quiet'], { cwd, shell: IS_WINDOWS }, (err) => + execFile('git', ['pull', '--ff-only', '--quiet'], { cwd }, (err) => err ? reject(err) : resolve(), ); }); @@ -58,7 +57,7 @@ export class SelfPullCapability implements WatchCapability { // Pop stash if we created one if (didStash) { try { - execFileSync('git', ['stash', 'pop'], { cwd, encoding: 'utf-8', shell: IS_WINDOWS }); + execFileSync('git', ['stash', 'pop'], { cwd, encoding: 'utf-8' }); } catch { console.log('⚠️ git stash pop failed (possible merge conflict) — changes remain in stash'); } @@ -68,11 +67,11 @@ export class SelfPullCapability implements WatchCapability { let sourceChanged = false; if (headBefore) { try { - const headAfter = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8', shell: IS_WINDOWS }).trim(); + const headAfter = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' }).trim(); if (headBefore !== headAfter) { const diff = execFileSync( 'git', ['diff', '--name-only', headBefore, headAfter, '--', 'packages/squad-cli/src/'], - { cwd, encoding: 'utf-8', shell: IS_WINDOWS }, + { cwd, encoding: 'utf-8' }, ).trim(); if (diff.length > 0) { sourceChanged = true; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/two-pass.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/two-pass.ts index 1cb7e6ee3..1941d42ee 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/two-pass.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/two-pass.ts @@ -5,7 +5,6 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { IS_WINDOWS } from '../agent-spawn.js'; const execFileAsync = promisify(execFile); @@ -31,11 +30,10 @@ export class TwoPassCapability implements WatchCapability { try { const memberLabels = new Set(context.roster.map(m => m.label)); - // Use shared round data when available (#923), otherwise fetch - const allItems = context.roundData?.issues - ?? await context.adapter.listWorkItems({ - tags: ['squad'], state: 'open', limit: 200, - }); + // Pass 1: lightweight list + const allItems = await context.adapter.listWorkItems({ + tags: ['squad'], state: 'open', limit: 200, + }); const total = allItems.length; // Filter to actionable @@ -54,7 +52,7 @@ export class TwoPassCapability implements WatchCapability { const { stdout: detailJson } = await execFileAsync('gh', [ 'issue', 'view', String(item.id), '--json', 'number,title,body,labels,assignees', - ], { maxBuffer: 5 * 1024 * 1024, shell: IS_WINDOWS }); + ], { maxBuffer: 5 * 1024 * 1024 }); hydrated.push(JSON.parse(detailJson)); } catch { hydrated.push({ number: item.id, title: item.title }); diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/wave-dispatch.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/wave-dispatch.ts index 747dd23f8..0169ef998 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/wave-dispatch.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/wave-dispatch.ts @@ -2,8 +2,8 @@ * WaveDispatch capability — parallel sub-task execution within issues. */ +import { execFile, type ChildProcess } from 'node:child_process'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; -import { buildAgentCommand, spawnAgent } from '../agent-spawn.js'; interface SubTask { description: string; @@ -34,13 +34,36 @@ function parseSubTasks(body: string | undefined): SubTask[] { return tasks; } +function buildAgentCommand(prompt: string, context: WatchContext): { cmd: string; args: string[] } { + if (context.agentCmd) { + const parts = context.agentCmd.trim().split(/\s+/); + return { cmd: parts[0]!, args: [...parts.slice(1), '-p', prompt] }; + } + const args = ['-p', prompt]; + if (context.copilotFlags) args.push(...context.copilotFlags.trim().split(/\s+/)); + return { cmd: 'copilot', args }; +} + function executeSubTask( prompt: string, context: WatchContext, timeoutMs: number, ): Promise<{ success: boolean; error?: string }> { const { cmd, args } = buildAgentCommand(prompt, context); - return spawnAgent(cmd, args, context.teamRoot, timeoutMs); + return new Promise((resolve) => { + const _cp: ChildProcess = execFile( + cmd, args, + { cwd: context.teamRoot, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 }, + (err) => { + if (err) { + const execErr = err as Error & { killed?: boolean }; + resolve({ success: false, error: execErr.killed ? 'Timed out' : execErr.message }); + } else { + resolve({ success: true }); + } + }, + ); + }); } export class WaveDispatchCapability implements WatchCapability { @@ -59,9 +82,8 @@ export class WaveDispatchCapability implements WatchCapability { const maxConcurrent = (context.config['maxConcurrent'] as number) ?? 1; const timeoutMs = ((context.config['timeout'] as number) ?? 30) * 60_000; - // Use shared round data when available (#923), otherwise fetch - const sdkItems = context.roundData?.issues - ?? await context.adapter.listWorkItems({ tags: ['squad'], state: 'open', limit: 50 }); + // Get issues from two-pass data if available, otherwise fetch + const sdkItems = await context.adapter.listWorkItems({ tags: ['squad'], state: 'open', limit: 50 }); let executed = 0; let failed = 0; diff --git a/packages/squad-cli/src/cli/commands/watch/config.ts b/packages/squad-cli/src/cli/commands/watch/config.ts index 629cbabb7..20caff3bf 100644 --- a/packages/squad-cli/src/cli/commands/watch/config.ts +++ b/packages/squad-cli/src/cli/commands/watch/config.ts @@ -31,6 +31,16 @@ export interface WatchConfig { verbose?: boolean; /** Preferred auth user for platform operations (e.g. gh auth switch --user). */ authUser?: string; + /** Notification level for watch events. */ + notifyLevel?: 'all' | 'important' | 'none'; + /** Hour to begin overnight mode (e.g. "22:00"). */ + overnightStart?: string; + /** Hour to end overnight mode (e.g. "08:00"). */ + overnightEnd?: string; + /** Path to a sentinel file — watch shuts down gracefully when removed. */ + sentinelFile?: string; + /** State persistence backend. */ + stateBackend?: 'worktree' | 'git-notes' | 'orphan' | 'external'; } const DEFAULTS: WatchConfig = { @@ -83,6 +93,12 @@ export function loadWatchConfig( ...(cliOverrides.capabilities ?? {}), }, verbose: cliOverrides.verbose ?? fileConfig.verbose ?? false, + authUser: cliOverrides.authUser ?? fileConfig.authUser, + notifyLevel: cliOverrides.notifyLevel ?? fileConfig.notifyLevel, + overnightStart: cliOverrides.overnightStart ?? fileConfig.overnightStart, + overnightEnd: cliOverrides.overnightEnd ?? fileConfig.overnightEnd, + sentinelFile: cliOverrides.sentinelFile ?? fileConfig.sentinelFile, + stateBackend: cliOverrides.stateBackend ?? fileConfig.stateBackend, }; return merged; @@ -106,10 +122,27 @@ function normalizeFileConfig(raw: Record): Partial } } if (typeof raw['logFile'] === 'string') result.logFile = raw['logFile']; + if (typeof raw['authUser'] === 'string') result.authUser = raw['authUser']; + if (typeof raw['notifyLevel'] === 'string') { + const level = raw['notifyLevel']; + if (level === 'all' || level === 'important' || level === 'none') { + result.notifyLevel = level; + } + } + if (typeof raw['overnightStart'] === 'string') result.overnightStart = raw['overnightStart']; + if (typeof raw['overnightEnd'] === 'string') result.overnightEnd = raw['overnightEnd']; + if (typeof raw['sentinelFile'] === 'string') result.sentinelFile = raw['sentinelFile']; + if (typeof raw['stateBackend'] === 'string') { + const backend = raw['stateBackend']; + const validBackends = ['worktree', 'git-notes', 'orphan', 'external'] as const; + if ((validBackends as readonly string[]).includes(backend)) { + result.stateBackend = backend as typeof validBackends[number]; + } + } // Everything else is a capability key const caps: Record> = {}; - const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'verbose', 'dispatchMode', 'logFile']); + const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'verbose', 'dispatchMode', 'logFile', 'authUser', 'notifyLevel', 'overnightStart', 'overnightEnd', 'sentinelFile', 'stateBackend']); for (const [key, value] of Object.entries(raw)) { if (reserved.has(key)) continue; if (typeof value === 'boolean' || (typeof value === 'object' && value !== null && !Array.isArray(value))) { diff --git a/packages/squad-cli/src/cli/commands/watch/external-loader.ts b/packages/squad-cli/src/cli/commands/watch/external-loader.ts new file mode 100644 index 000000000..8f1fb63fd --- /dev/null +++ b/packages/squad-cli/src/cli/commands/watch/external-loader.ts @@ -0,0 +1,121 @@ +/** + * External capability loader — scans .squad/capabilities/ for user-defined + * WatchCapability modules and registers them alongside built-in capabilities. + */ + +import { existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { CapabilityRegistry } from './registry.js'; +import type { WatchCapability } from './types.js'; +import { GREEN, YELLOW, RED, BOLD, DIM, RESET } from '../../core/output.js'; + +/** Required fields on a valid WatchCapability. */ +const REQUIRED_FIELDS: ReadonlyArray = [ + 'name', + 'description', + 'configShape', + 'requires', + 'phase', + 'preflight', + 'execute', +]; + +/** + * Load external WatchCapability modules from `{teamRoot}/.squad/capabilities/`. + * + * - Skips silently when the directory does not exist. + * - Logs a warning and continues when a file fails to load. + * - Returns the count of successfully loaded capabilities. + */ +export async function loadExternalCapabilities( + teamRoot: string, + registry: CapabilityRegistry, +): Promise { + const capDir = join(teamRoot, '.squad', 'capabilities'); + + if (!existsSync(capDir)) { + return 0; + } + + const entries = await readdir(capDir); + const jsFiles = entries.filter(f => f.endsWith('.js')).sort(); + + if (jsFiles.length === 0) { + return 0; + } + + // Security: warn user before executing external code + console.log( + `\n${YELLOW}${BOLD}⚠️ SECURITY: Loading ${jsFiles.length} external capability file(s) from .squad/capabilities/${RESET}`, + ); + for (const f of jsFiles) { + console.log(`${DIM} • ${f}${RESET}`); + } + console.log( + `${YELLOW} External capabilities execute with FULL process permissions (file system, network, credentials).${RESET}`, + ); + console.log( + `${YELLOW} Review these files if you did not author them.${RESET}\n`, + ); + + let loaded = 0; + + for (const filename of jsFiles) { + const filePath = join(capDir, filename); + try { + const mod = await import(pathToFileURL(filePath).href); + let cap: WatchCapability = mod.default ?? mod; + + // Support class-based capabilities with a no-arg constructor + if (typeof cap === 'function') { + cap = new (cap as new () => WatchCapability)(); + } + + // Validate required fields + const missing = REQUIRED_FIELDS.filter(f => !(f in cap)); + if (missing.length > 0) { + console.log( + `${YELLOW}⚠️ Failed to load capability from ${filename}: missing fields: ${missing.join(', ')}${RESET}`, + ); + continue; + } + + // Validate field types + if (typeof cap.preflight !== 'function' || typeof cap.execute !== 'function') { + console.log( + `${YELLOW}⚠️ Failed to load capability from ${filename}: preflight and execute must be functions${RESET}`, + ); + continue; + } + const validPhases = ['pre-scan', 'post-triage', 'post-execute', 'housekeeping']; + if (!validPhases.includes(cap.phase)) { + console.log( + `${YELLOW}⚠️ Failed to load capability from ${filename}: invalid phase '${cap.phase}' (must be one of: ${validPhases.join(', ')})${RESET}`, + ); + continue; + } + + // Security: block hijacking of built-in capabilities + if (registry.get(cap.name)) { + console.log( + `${YELLOW}⚠️ External capability "${cap.name}" conflicts with built-in — skipped (potential hijack)${RESET}`, + ); + continue; + } + + registry.register(cap); + console.log( + `${GREEN}✅ Loaded external capability: ${cap.name} (phase: ${cap.phase})${RESET}`, + ); + loaded++; + } catch (err) { + console.log( + `${YELLOW}⚠️ Failed to load capability from ${filename}: ${err instanceof Error ? err.message : String(err)}${RESET}`, + ); + } + } + + return loaded; +} diff --git a/packages/squad-cli/src/cli/commands/watch/health.ts b/packages/squad-cli/src/cli/commands/watch/health.ts index fdceabd0f..8dbaab025 100644 --- a/packages/squad-cli/src/cli/commands/watch/health.ts +++ b/packages/squad-cli/src/cli/commands/watch/health.ts @@ -10,8 +10,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; -import { execFileSync } from 'node:child_process'; -import { IS_WINDOWS } from './agent-spawn.js'; +import { spawnSync } from 'node:child_process'; /** Shape of the PID file written by runWatch at startup. */ export interface WatchPidInfo { @@ -78,21 +77,19 @@ function formatUptime(ms: number): string { * Duplicated from index.ts to avoid circular imports — the canonical * version lives in `getActiveGhUser()` in the watch index. */ -/** Probes gh auth status — captures both stdout and stderr since gh may write to either. */ +/** Probes current gh user — uses `gh api user` which writes to stdout reliably. */ function probeCurrentGhUser(): string | undefined { try { - const result = execFileSync('gh', ['auth', 'status', '--active'], { + const result = spawnSync('gh', ['api', 'user', '-q', '.login'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - shell: IS_WINDOWS, - timeout: 10_000, + timeout: 5000, }); - const match = result.match(/account\s+(\S+)/); - return match?.[1]; - } catch (e) { - const stderr = (e as { stderr?: string }).stderr ?? ''; - const match = stderr.match(/account\s+(\S+)/); - return match?.[1]; + const login = (result.stdout ?? '').trim(); + if (login && result.status === 0) return login; + return undefined; + } catch { + return undefined; } } diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index 5b01c315a..9afea3def 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -15,8 +15,6 @@ import { FSStorageProvider } from '@bradygaster/squad-sdk'; const storage = new FSStorageProvider(); const execFileAsync = promisify(execFile); -import { IS_WINDOWS, buildAgentCommand as buildAgentCommandShared, spawnAgent, resolveCopilotCmd } from './agent-spawn.js'; - import { detectSquadDir } from '../../core/detect-squad-dir.js'; import { fatal } from '../../core/errors.js'; import { GREEN, RED, DIM, BOLD, RESET, YELLOW } from '../../core/output.js'; @@ -39,7 +37,7 @@ import { createPlatformAdapter } from '@bradygaster/squad-sdk/platform'; import type { PlatformAdapter, WorkItem, PullRequest as SdkPullRequest } from '@bradygaster/squad-sdk/platform'; import type { WatchConfig } from './config.js'; -import type { WatchCapability, WatchContext, WatchPhase, CapabilityResult, RoundData } from './types.js'; +import type { WatchCapability, WatchContext, WatchPhase, CapabilityResult } from './types.js'; import { CapabilityRegistry } from './registry.js'; import { createDefaultRegistry } from './capabilities/index.js'; import { createVerboseLogger, type VerboseLogger } from './verbose.js'; @@ -48,12 +46,13 @@ import { createVerboseLogger, type VerboseLogger } from './verbose.js'; export type { WatchConfig } from './config.js'; export { loadWatchConfig } from './config.js'; -export type { WatchCapability, WatchContext, WatchPhase, PreflightResult, CapabilityResult, RoundData } from './types.js'; +export type { WatchCapability, WatchContext, WatchPhase, PreflightResult, CapabilityResult } from './types.js'; export { CapabilityRegistry } from './registry.js'; export { createDefaultRegistry } from './capabilities/index.js'; export { createVerboseLogger, type VerboseLogger } from './verbose.js'; +export { loadExternalCapabilities } from './external-loader.js'; +export { PidTracker, type TrackedProcess } from './pid-tracker.js'; export { getWatchHealth, writePidFile, removePidFile, getPidPath, isProcessAlive, type WatchPidInfo } from './health.js'; -export { IS_WINDOWS, buildAgentCommand as buildAgentCommandShared, spawnWithTimeout, spawnAgent } from './agent-spawn.js'; // ── Watch Platform Abstraction ─────────────────────────────────── @@ -140,7 +139,7 @@ async function editWorkItem( if (options.addAssignee) { if (adapter.type === 'github') { try { - await execFileAsync('gh', ['issue', 'edit', String(id), '--add-assignee', options.addAssignee], { shell: IS_WINDOWS }); + await execFileAsync('gh', ['issue', 'edit', String(id), '--add-assignee', options.addAssignee]); } catch { /* best-effort */ } } else if (adapter.type === 'azure-devops') { const assignee = options.addAssignee === '@me' ? '' : options.addAssignee; @@ -151,7 +150,7 @@ async function editWorkItem( '--id', String(id), '--fields', `System.AssignedTo=${assignee}`, '--output', 'json', - ], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], shell: IS_WINDOWS }); + ], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); } catch { /* best-effort */ } } } @@ -171,15 +170,36 @@ export interface BoardState { executed: number; } +/** Outcome of a runCheck call — wraps BoardState with scan status. */ +export type RunCheckStatus = 'ok' | 'rate-limited' | 'error'; + +export interface RunCheckResult { + state: BoardState; + status: RunCheckStatus; +} + export interface ReportBoardOptions { notifyLevel?: 'all' | 'important' | 'none'; machineName?: string; repoName?: string; + /** When set, overrides the "Board is clear" message for failed scans. */ + scanStatus?: RunCheckStatus; } export function reportBoard(state: BoardState, round: number, options?: ReportBoardOptions): void { const level = options?.notifyLevel ?? 'all'; const total = Object.values(state).reduce((a, b) => a + b, 0); + const scanStatus = options?.scanStatus ?? 'ok'; + + // Rate-limit / error warnings always print (bypass notifyLevel suppression) + if (total === 0 && scanStatus === 'rate-limited') { + console.log(`${YELLOW}⚠ API rate limited — skipping this round (retry in next interval)${RESET}`); + return; + } + if (total === 0 && scanStatus === 'error') { + console.log(`${YELLOW}⚠ Board scan failed — skipping this round (retry in next interval)${RESET}`); + return; + } if (level === 'none') return; if (level === 'important' && total === 0) return; @@ -214,12 +234,9 @@ type PRBoardState = Pick, adapter: PlatformAdapter, vlog?: VerboseLogger, roundData?: RoundData): Promise { +async function checkPRs(roster: ReturnType, adapter: PlatformAdapter, vlog?: VerboseLogger): Promise { const timestamp = new Date().toLocaleTimeString(); - // Use shared round data when available (#923), otherwise fetch - const prs: WatchPullRequest[] = roundData - ? roundData.pullRequests.map(toWatchPullRequest) - : await listWatchPullRequests(adapter, { state: 'open', limit: 20 }); + const prs = await listWatchPullRequests(adapter, { state: 'open', limit: 20 }); const squadPRs = prs.filter(pr => pr.labels.some(l => l.name.startsWith('squad')) || pr.headRefName.startsWith('squad/'), ); @@ -302,14 +319,10 @@ async function runCheck( capabilities: MachineCapabilities | null, adapter: PlatformAdapter, vlog?: VerboseLogger, - roundData?: RoundData, -): Promise { +): Promise { const timestamp = new Date().toLocaleTimeString(); try { - // Use shared round data when available (#923), otherwise fetch - const issues: WatchWorkItem[] = roundData - ? roundData.issues.map(toWatchWorkItem) - : await listWatchWorkItems(adapter, { label: 'squad', state: 'open', limit: 20 }); + const issues = await listWatchWorkItems(adapter, { label: 'squad', state: 'open', limit: 20 }); vlog?.log(`Issues found: ${issues.length} total`); for (const issue of issues) { @@ -338,12 +351,7 @@ async function runCheck( let unassignedCopilot: WatchWorkItem[] = []; if (hasCopilot && autoAssign) { try { - // Use shared round data when available (#923) — filter from already-fetched issues - const copilotIssues: WatchWorkItem[] = roundData - ? roundData.issues - .filter(wi => wi.tags.includes('squad:copilot')) - .map(toWatchWorkItem) - : await listWatchWorkItems(adapter, { label: 'squad:copilot', state: 'open', limit: 10 }); + const copilotIssues = await listWatchWorkItems(adapter, { label: 'squad:copilot', state: 'open', limit: 10 }); unassignedCopilot = copilotIssues.filter(i => !i.assignees || i.assignees.length === 0); } catch { /* label may not exist */ } } @@ -375,11 +383,17 @@ async function runCheck( } } - const prState = await checkPRs(roster, adapter, vlog, roundData); - return { untriaged: untriaged.length, assigned: assignedIssues.length, executed: 0, ...prState }; + const prState = await checkPRs(roster, adapter, vlog); + return { state: { untriaged: untriaged.length, assigned: assignedIssues.length, executed: 0, ...prState }, status: 'ok' }; } catch (e) { - console.error(`${RED}✗${RESET} [${timestamp}] Check failed: ${(e as Error).message}`); - return emptyBoardState(); + const err = e as Error; + const limited = isRateLimitError(err); + if (limited) { + console.log(`${YELLOW}⚠${RESET} [${timestamp}] API rate limited — board scan skipped`); + } else { + console.error(`${RED}✗${RESET} [${timestamp}] Check failed: ${err.message}`); + } + return { state: emptyBoardState(), status: limited ? 'rate-limited' : 'error' }; } } @@ -595,22 +609,20 @@ export function buildAgentCommand( const prompt = `Work on issue #${issue.number}: ${issue.title}. Read the issue body for full details.`; if (options.agentCmd) { const parts = options.agentCmd.trim().split(/\s+/); - return { cmd: parts[0]!, args: [...parts.slice(1), '--message', prompt] }; + return { cmd: parts[0]!, args: [...parts.slice(1), '-p', prompt] }; } - // Default: detect available copilot CLI at runtime (cached) - const { cmd, cmdPrefix } = resolveCopilotCmd(); - const args = [...cmdPrefix, '--message', prompt]; + const args = ['-p', prompt]; if (options.copilotFlags) args.push(...options.copilotFlags.trim().split(/\s+/)); - return { cmd, args }; + return { cmd: 'copilot', args }; } export async function selfPull(teamRoot: string): Promise { try { await new Promise((resolve, reject) => { - execFile('git', ['fetch', '--quiet'], { cwd: teamRoot, shell: IS_WINDOWS }, (err) => (err ? reject(err) : resolve())); + execFile('git', ['fetch', '--quiet'], { cwd: teamRoot }, (err) => (err ? reject(err) : resolve())); }); await new Promise((resolve, reject) => { - execFile('git', ['pull', '--ff-only', '--quiet'], { cwd: teamRoot, shell: IS_WINDOWS }, (err) => (err ? reject(err) : resolve())); + execFile('git', ['pull', '--ff-only', '--quiet'], { cwd: teamRoot }, (err) => (err ? reject(err) : resolve())); }); } catch { console.log(`${DIM}⚠ selfPull: git pull skipped (not on a tracking branch or conflicts)${RESET}`); @@ -630,7 +642,7 @@ export async function executeIssue( const { cmd, args } = buildAgentCommand(issue, teamRoot, options); console.log(`${GREEN}▶${RESET} [${ts}] Executing #${issue.number} "${issue.title}" → ${cmd} ${args.join(' ')}`); return new Promise((resolve) => { - execFile(cmd, args, { cwd: teamRoot, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024, shell: IS_WINDOWS }, (err) => { + execFile(cmd, args, { cwd: teamRoot, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 }, (err) => { if (err) { const execErr = err as Error & { killed?: boolean }; const msg = execErr.killed ? `Timed out after ${options.issueTimeoutMinutes ?? 30}m` : execErr.message; @@ -702,7 +714,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig // Auth check (verbose only — avoid unnecessary process spawn on every startup) if (config.verbose && adapter.type === 'github') { try { - const authOut = execFileSync('gh', ['auth', 'status'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], shell: IS_WINDOWS }).trim(); + const authOut = execFileSync('gh', ['auth', 'status'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); const activeAccount = authOut.match(/Logged in to .* account (\S+)/)?.[1]; vlog.log(`Auth: ${activeAccount ?? 'unknown'}`); } catch { @@ -715,8 +727,8 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig if (!(await ghAvailable())) fatal('gh CLI not found — install from https://cli.github.com'); if (!(await ghAuthenticated())) fatal('gh CLI not authenticated — run: gh auth login'); } else if (adapter.type === 'azure-devops') { - try { await execFileAsync('az', ['devops', '-h'], { shell: IS_WINDOWS }); } catch { fatal('az CLI not found'); } - try { await execFileAsync('az', ['account', 'show'], { shell: IS_WINDOWS }); } catch { fatal('az CLI not authenticated — run: az login'); } + try { await execFileAsync('az', ['devops', '-h']); } catch { fatal('az CLI not found'); } + try { await execFileAsync('az', ['account', 'show']); } catch { fatal('az CLI not authenticated — run: az login'); } } // Parse team.md @@ -768,6 +780,24 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig // ── Capability system setup ──────────────────────────────────── const registry = createDefaultRegistry(); + + // Load external capabilities from .squad/capabilities/ + const { loadExternalCapabilities } = await import('./external-loader.js'); + await loadExternalCapabilities(teamRoot, registry); + + // PID tracking for child process cleanup + const { PidTracker } = await import('./pid-tracker.js'); + const pidTracker = new PidTracker(teamRoot); + + // Clean up orphans from previous crashed run + const staleKilled = pidTracker.cleanupStale(); + if (staleKilled > 0) { + console.log(`${YELLOW}⚠️ Cleaned up ${staleKilled} orphaned process(es) from previous run${RESET}`); + } + + // Register exit handlers for graceful cleanup + pidTracker.registerExitHandlers(); + const baseContext: WatchContext = { teamRoot, adapter, @@ -777,6 +807,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig agentCmd: config.agentCmd, copilotFlags: config.copilotFlags, verbose: config.verbose, + pidTracker, }; const enabledCapabilities = await preflightCapabilities(registry, config, baseContext); @@ -869,17 +900,11 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig } round++; - - // ── Shared round data (#923): fetch issues + PRs ONCE ──────── - const roundIssues = await adapter.listWorkItems({ tags: ['squad'], state: 'open', limit: 200 }); - const roundPRs = await adapter.listPullRequests({ status: 'active', limit: 20 }); - const roundData = { issues: roundIssues, pullRequests: roundPRs, fetchedAt: new Date() }; - const roundContext: WatchContext = { ...baseContext, round, roundData }; + const roundContext: WatchContext = { ...baseContext, round }; // Fix 1: Print round start marker console.log(`\n${DIM}Starting round ${round}...${RESET}`); vlog.section(`Round ${round}`); - vlog.log(`Shared fetch: ${roundIssues.length} issues, ${roundPRs.length} PRs`); // Phase 1: pre-scan (self-pull, subsquad discovery) await runPhase('pre-scan', enabledCapabilities, roundContext, config); @@ -890,8 +915,22 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig console.log(`${DIM}📂 Discovered ${subSquads.length} subsquad(s): ${subSquads.map(s => s.name).join(', ')}${RESET}`); } - // Core: triage (always runs — not a capability) — uses shared roundData - const roundState = await runCheck(rules, modules, roster, hasCopilot, autoAssign, capabilities, adapter, vlog, roundData); + // Core: triage (always runs — not a capability) + const checkResult = await runCheck(rules, modules, roster, hasCopilot, autoAssign, capabilities, adapter, vlog); + const roundState = checkResult.state; + + // Short-circuit remaining phases when the scan failed or was rate-limited + if (checkResult.status !== 'ok') { + reportBoard(roundState, round, { scanStatus: checkResult.status }); + const nextPollTime = new Date(Date.now() + interval * 60 * 1000); + console.log(`${DIM}Next poll at ${nextPollTime.toLocaleTimeString()}${RESET}`); + // Do NOT count a failed scan as a circuit-breaker success + if (cbState.status === 'half-open') { + cbState.consecutiveSuccesses = 0; + } + saveCBState(squadDirInfo.path, cbState); + return; + } // Phase 2: post-triage (two-pass hydration) await runPhase('post-triage', enabledCapabilities, roundContext, config); diff --git a/packages/squad-cli/src/cli/commands/watch/pid-tracker.ts b/packages/squad-cli/src/cli/commands/watch/pid-tracker.ts new file mode 100644 index 000000000..98b189215 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/watch/pid-tracker.ts @@ -0,0 +1,141 @@ +/** + * Tracks child process PIDs spawned during watch rounds. + * Enables cleanup of orphaned processes on exit or restart. + */ + +import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { YELLOW, DIM, RESET } from '../../core/output.js'; + +export interface TrackedProcess { + pid: number; + name: string; + spawnedAt: string; // ISO 8601 +} + +export class PidTracker { + private readonly pidFile: string; + private tracked: TrackedProcess[] = []; + + constructor(teamRoot: string) { + this.pidFile = join(teamRoot, '.squad', '.watch-pids'); + } + + /** Add a child PID to tracking. */ + track(pid: number, name: string): void { + this.tracked.push({ pid, name, spawnedAt: new Date().toISOString() }); + this.save(); + } + + /** Remove a PID from tracking (process exited normally). */ + untrack(pid: number): void { + this.tracked = this.tracked.filter(p => p.pid !== pid); + this.save(); + } + + /** Check if a process is still alive. */ + private isAlive(pid: number): boolean { + try { + process.kill(pid, 0); // signal 0 = existence check, doesn't kill + return true; + } catch { + return false; + } + } + + /** Kill a process tree. Cross-platform. */ + private killTree(pid: number): boolean { + try { + if (process.platform === 'win32') { + execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000 }); + } else { + // Note: We only kill the direct PID. Grandchild cleanup requires + // spawning with detached:true, which is controlled by the Execute + // capability. For now, direct PID kill is the honest approach. + process.kill(pid, 'SIGKILL'); + } + return true; + } catch { + return false; + } + } + + /** Kill all tracked processes and clean up the PID file. */ + cleanup(): { killed: number; total: number } { + let killed = 0; + const total = this.tracked.length; + + for (const proc of this.tracked) { + if (this.isAlive(proc.pid)) { + if (this.killTree(proc.pid)) { + killed++; + } + } + } + + this.tracked = []; + this.deletePidFile(); + return { killed, total }; + } + + /** On startup: check for stale PID file from a previous crashed run. + * Kill any orphans that are still alive. */ + cleanupStale(): number { + if (!existsSync(this.pidFile)) return 0; + + let staleKilled = 0; + try { + const content = readFileSync(this.pidFile, 'utf-8'); + const stale: TrackedProcess[] = JSON.parse(content); + + for (const proc of stale) { + if (this.isAlive(proc.pid)) { + if (this.killTree(proc.pid)) { + staleKilled++; + console.log(`${YELLOW}⚠️ Killed orphaned process: ${proc.name} (PID ${proc.pid}, spawned ${proc.spawnedAt})${RESET}`); + } + } + } + } catch { + // Corrupted PID file — just delete it + } + + this.deletePidFile(); + return staleKilled; + } + + /** Register process exit handlers for cleanup. */ + registerExitHandlers(): void { + const doCleanup = () => { + const result = this.cleanup(); + if (result.killed > 0) { + // Can't use console.log in exit handler reliably, but try + try { + console.log(`${DIM}Cleaned up ${result.killed} child process(es)${RESET}`); + } catch { /* ignore */ } + } + }; + + process.on('exit', doCleanup); + process.on('SIGINT', () => { doCleanup(); process.exit(130); }); + process.on('SIGTERM', () => { doCleanup(); process.exit(143); }); + process.on('uncaughtException', (err) => { + console.error('Uncaught exception:', err); + doCleanup(); + process.exit(1); + }); + } + + private save(): void { + try { + writeFileSync(this.pidFile, JSON.stringify(this.tracked, null, 2), 'utf-8'); + } catch { /* ignore — best effort */ } + } + + private deletePidFile(): void { + try { + if (existsSync(this.pidFile)) unlinkSync(this.pidFile); + } catch { /* ignore */ } + } +} diff --git a/packages/squad-cli/src/cli/commands/watch/types.ts b/packages/squad-cli/src/cli/commands/watch/types.ts index 0d674f3e7..05ee68411 100644 --- a/packages/squad-cli/src/cli/commands/watch/types.ts +++ b/packages/squad-cli/src/cli/commands/watch/types.ts @@ -5,28 +5,11 @@ * main loop stays thin and each feature is testable in isolation. */ -import type { PlatformAdapter, WorkItem, PullRequest } from '@bradygaster/squad-sdk/platform'; +import type { PlatformAdapter } from '@bradygaster/squad-sdk/platform'; /** Phase within a single watch round. */ export type WatchPhase = 'pre-scan' | 'post-triage' | 'post-execute' | 'housekeeping'; -/** - * Shared data fetched ONCE at round start and passed to every capability. - * - * Avoids redundant API calls — each capability filters this data instead - * of making its own `listWorkItems()` call. - * - * @see https://github.com/bradygaster/squad/issues/923 - */ -export interface RoundData { - /** All open squad-labelled work items, fetched once with a generous limit. */ - issues: WorkItem[]; - /** All open pull requests, fetched once. */ - pullRequests: PullRequest[]; - /** When this data was fetched. */ - fetchedAt: Date; -} - /** Result of a capability preflight check. */ export interface PreflightResult { ok: boolean; @@ -56,12 +39,8 @@ export interface WatchContext { copilotFlags?: string; /** Verbose diagnostic output enabled. */ verbose?: boolean; - /** - * Shared round data — fetched once at the start of each round. - * Capabilities should read from here instead of calling adapter.listWorkItems(). - * @see https://github.com/bradygaster/squad/issues/923 - */ - roundData?: RoundData; + /** PID tracker for child process cleanup (optional — only set when watch is running). */ + pidTracker?: { track(pid: number, name: string): void; untrack(pid: number): void }; } /** Contract that every watch capability must implement. */ diff --git a/packages/squad-cli/src/cli/core/gh-cli.ts b/packages/squad-cli/src/cli/core/gh-cli.ts index ca19f5d7d..6dfc8c434 100644 --- a/packages/squad-cli/src/cli/core/gh-cli.ts +++ b/packages/squad-cli/src/cli/core/gh-cli.ts @@ -5,7 +5,6 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -const IS_WINDOWS = process.platform === 'win32'; const execFileAsync = promisify(execFile); export interface GhIssue { @@ -52,7 +51,7 @@ export interface GhPrListOptions { */ export async function ghAvailable(): Promise { try { - await execFileAsync('gh', ['--version'], { shell: IS_WINDOWS }); + await execFileAsync('gh', ['--version']); return true; } catch { return false; @@ -64,7 +63,7 @@ export async function ghAvailable(): Promise { */ export async function ghAuthenticated(): Promise { try { - await execFileAsync('gh', ['auth', 'status'], { shell: IS_WINDOWS }); + await execFileAsync('gh', ['auth', 'status']); return true; } catch { return false; @@ -87,7 +86,7 @@ export async function ghIssueList(options: GhListOptions = {}): Promise { const { stdout } = await execFileAsync('gh', [ 'api', 'rate_limit', '--jq', '.resources.core | {remaining, limit, reset}', - ], { shell: IS_WINDOWS }); + ]); const data = JSON.parse(stdout); return { remaining: data.remaining, @@ -154,12 +153,13 @@ export async function ghRateLimitCheck(): Promise { } /** - * Detect if an error is a GitHub 429 rate limit error. + * Detect if an error is a GitHub rate-limit error (429 or explicit rate-limit messages). + * Does NOT match bare 403 — that indicates an auth/permissions error, not a transient rate limit. */ export function isRateLimitError(err: unknown): boolean { if (err instanceof Error) { const msg = err.message.toLowerCase(); - return msg.includes('rate limit') || msg.includes('secondary rate') || msg.includes('403'); + return msg.includes('rate limit') || msg.includes('secondary rate') || msg.includes('429'); } return false; } diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 22ec44af3..abaefb19a 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -3,7 +3,6 @@ * 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'; @@ -18,48 +17,6 @@ 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, @@ -166,34 +123,25 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // Detect project type const projectType = detectProjectType(dest); - // ── Parent git repo detection ───────────────────────────────────── + // ── Monorepo / subfolder detection ─────────────────────────────── // Copilot resolves .github/agents/ relative to the git root. - // If CWD is inside a parent git repo, the agent file will be - // invisible to copilot because the git root points elsewhere. - try { - const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: dest, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - }).trim().replace(/\//g, path.sep); - const normalDest = path.resolve(dest); - const normalGitRoot = path.resolve(gitRoot); - if (normalDest.toLowerCase() !== normalGitRoot.toLowerCase()) { - console.log(); - console.log(`${YELLOW}${BOLD}⚠ Parent git repo detected${RESET}`); - console.log(`${YELLOW} Git root: ${normalGitRoot}${RESET}`); - console.log(`${YELLOW} You're in: ${normalDest}${RESET}`); - console.log(); - console.log(`${DIM}Copilot resolves .github/agents/ from the git root, not from here.${RESET}`); - console.log(`${DIM}The Squad agent won't be visible to copilot in this folder.${RESET}`); - console.log(); - // Auto-fix: run git init to create a repo boundary here - console.log(`${CYAN}${BOLD}→${RESET} Running ${CYAN}git init${RESET} to create a repo boundary...`); - execFileSync('git', ['init'], { cwd: dest, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); - console.log(`${GREEN}${BOLD}✓${RESET} Initialized git repo at ${normalDest}`); - console.log(); - } - } catch { - // No git available or not in a git repo — that's fine, continue normally. - // Copilot will fall back to CWD for .github/agents/ discovery. + // If CWD is a subfolder of a larger repo (monorepo), we place + // squad.agent.md at the git root and .squad/ in the subfolder. + // Never run `git init` — it creates broken nested repos (#939). + let agentFileRoot = dest; // default: place agent file relative to dest + const parentGitRoot = detectParentGitRepo(dest); + if (parentGitRoot) { + console.log(); + console.log(`${CYAN}${BOLD}📦 Monorepo detected${RESET}`); + console.log(`${DIM} Git root: ${parentGitRoot}${RESET}`); + console.log(`${DIM} You're in: ${path.resolve(dest)}${RESET}`); + console.log(); + console.log(`${DIM}squad.agent.md → git root (.github/agents/)${RESET}`); + console.log(`${DIM}.squad/ → here (${path.basename(path.resolve(dest))}/)${RESET}`); + console.log(); + // Place the agent file at the git root so Copilot can find it. + // Team state (.squad/) stays in cwd — resolved via cwd at runtime. + agentFileRoot = parentGitRoot; } // Detect squad directory @@ -252,6 +200,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // Build SDK options const initOptions: InitOptions = { teamRoot: dest, + agentFileRoot, projectName: path.basename(dest) || 'my-project', agents: [ { @@ -292,16 +241,17 @@ 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 } process.off('SIGINT', sigintHandler); // Ensure version is fully stamped in squad.agent.md - const agentPath = path.join(dest, '.github', 'agents', 'squad.agent.md'); + const agentPath = path.join(agentFileRoot, '.github', 'agents', 'squad.agent.md'); if (storage.existsSync(agentPath)) { stampVersion(agentPath, version); } @@ -330,19 +280,6 @@ 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/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 59e64c615..975fb8f98 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -431,6 +431,20 @@ export function ensureCastingDefaults(dest: string, templatesDir?: string): stri return created; } +/** + * Warn when a built-in skill has been customized and is about to be overwritten. + * Normalizes line endings before comparison to avoid false positives from CRLF differences. + */ +function warnIfSkillCustomized(srcPath: string, destPath: string, sourceName: string): void { + if (!storage.existsSync(destPath)) return; + const existing = (storage.readSync(destPath) ?? '').replace(/\r\n/g, '\n'); + const template = (storage.readSync(srcPath) ?? '').replace(/\r\n/g, '\n'); + if (existing && existing !== template) { + const skillName = sourceName.split('/')[1] ?? sourceName; + warn(`Skill '${skillName}' has been customized — overwriting with built-in version`); + } +} + /** * Sync manifest-declared skills to .copilot/skills/, respecting overwriteOnUpgrade. * Only skills listed in TEMPLATE_MANIFEST are installed — not the entire templates/skills/ dir. @@ -448,6 +462,7 @@ function syncAllSkills(dest: string, templatesDir: string): number { if (!storage.existsSync(srcPath)) continue; if (!entry.overwriteOnUpgrade && storage.existsSync(destPath)) continue; + warnIfSkillCustomized(srcPath, destPath, entry.source); storage.mkdirSync(path.dirname(destPath), { recursive: true }); storage.copySync(srcPath, destPath); synced++; @@ -614,6 +629,9 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr if (!storage.existsSync(srcPath)) continue; + if (file.source.startsWith('skills/')) { + warnIfSkillCustomized(srcPath, destPath, file.source); + } storage.mkdirSync(path.dirname(destPath), { recursive: true }); storage.copySync(srcPath, destPath); diff --git a/packages/squad-cli/src/cli/index.ts b/packages/squad-cli/src/cli/index.ts index 191439e85..b56cb21a0 100644 --- a/packages/squad-cli/src/cli/index.ts +++ b/packages/squad-cli/src/cli/index.ts @@ -40,5 +40,3 @@ 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'; diff --git a/packages/squad-cli/src/cli/shell/index.ts b/packages/squad-cli/src/cli/shell/index.ts index ddff37d91..d45e878aa 100644 --- a/packages/squad-cli/src/cli/shell/index.ts +++ b/packages/squad-cli/src/cli/shell/index.ts @@ -986,6 +986,26 @@ export async function runShell(): Promise { files: result.filesCreated.length, }); + // Production guard: verify roster was actually populated before proceeding + const verifyTeamPath = join(teamRoot, '.squad', 'team.md'); + const verifyContent = storage.readSync(verifyTeamPath) ?? ''; + if (!hasRosterEntries(verifyContent)) { + debugLog('finalizeCast: roster empty after createTeam — aborting dispatch'); + // Clean up .init-prompt to prevent auto-retry loop on next startup + const initPromptCleanup = join(teamRoot, '.squad', '.init-prompt'); + if (storage.existsSync(initPromptCleanup)) { + try { await storage.delete(initPromptCleanup); } catch { /* ignore */ } + } + shellApi?.addMessage({ + role: 'system', + content: '⚠ Team creation completed but roster is empty — skipping dispatch. Check .squad/team.md.', + timestamp: new Date(), + }); + shellApi?.setActivityHint(undefined); + shellApi?.setProcessing(false); + return; + } + shellApi?.addMessage({ role: 'system', content: `✅ Team hired! ${result.membersCreated.length} members created.`, diff --git a/packages/squad-cli/src/cli/templates/capabilities/issue-pipeline.sample.js b/packages/squad-cli/src/cli/templates/capabilities/issue-pipeline.sample.js new file mode 100644 index 000000000..4435a605b --- /dev/null +++ b/packages/squad-cli/src/cli/templates/capabilities/issue-pipeline.sample.js @@ -0,0 +1,40 @@ +/** + * Sample: Issue Pipeline capability + * + * Copy to .squad/capabilities/issue-pipeline.js to use. + * Enforces: issue → branch → implement → PR → review → merge + * + * Each LLM invocation is a fresh copilot session — no drift. + */ + +export default { + name: 'issue-pipeline', + description: 'Deterministic issue→branch→PR→review→merge pipeline', + configShape: 'object', + requires: ['gh'], + phase: 'post-triage', + + async preflight(context) { + // Check gh CLI is available + try { + const { execFileSync } = await import('node:child_process'); + execFileSync('gh', ['--version'], { stdio: 'ignore' }); + return { ok: true }; + } catch { + return { ok: false, reason: 'gh CLI not available' }; + } + }, + + async execute(context) { + // This is a reference implementation — customize for your workflow + const { execFileSync } = await import('node:child_process'); + + // Find issues that are triaged but don't have a PR yet + // (Real implementation would check for squad labels, existing PRs, etc.) + + return { + success: true, + summary: 'Issue pipeline: checked for actionable issues (sample — customize for your workflow)', + }; + }, +}; diff --git a/packages/squad-cli/templates/mcp-config.md b/packages/squad-cli/templates/mcp-config.md index 2e361ee4b..f38425e4c 100644 --- a/packages/squad-cli/templates/mcp-config.md +++ b/packages/squad-cli/templates/mcp-config.md @@ -2,8 +2,6 @@ MCP (Model Context Protocol) servers extend Squad with tools for external services — Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them. -> **Full patterns:** Read `.squad/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, and graceful degradation. - ## Config File Locations Users configure MCP servers at these locations (checked in priority order): diff --git a/packages/squad-cli/templates/scribe-charter.md b/packages/squad-cli/templates/scribe-charter.md index 78d4d971b..308b780e2 100644 --- a/packages/squad-cli/templates/scribe-charter.md +++ b/packages/squad-cli/templates/scribe-charter.md @@ -43,7 +43,7 @@ After every substantial work session: - **Exact duplicates:** If two blocks share the same heading, keep the first and remove the rest. - **Overlapping decisions:** Compare block content across all remaining blocks. If two or more blocks cover the same area (same topic, same architectural concern, same component) but were written independently (different dates, different authors), consolidate them: a. Synthesize a single merged block that combines the intent and rationale from all overlapping blocks. - b. Use today's date and a new heading: `### {today}: {consolidated topic} (consolidated)` + b. Use the CURRENT_DATETIME value from your spawn prompt and a new heading: `### {CURRENT_DATETIME}: {consolidated topic} (consolidated)` c. Credit all original authors: `**By:** {Name1}, {Name2}` d. Under **What:**, combine the decisions. Note any differences or evolution. e. Under **Why:**, merge the rationale, preserving unique reasoning from each. diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index 1a3813a33..01e18dfad 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -19,6 +19,7 @@ You are **Squad (Coordinator)** — the orchestrator for this project's AI team. - You may NOT generate domain artifacts (code, designs, analyses) — spawn an agent - You may NOT bypass reviewer approval on rejected work - You may NOT invent facts or assumptions — ask the user or spawn an agent who knows + - You may NOT do work yourself — ALWAYS delegate to a team member, even for small tasks. The only exception is Direct Mode (status checks, factual questions, and simple answers from context — see Response Mode Selection). Check: Does `.squad/team.md` exist? (fall back to `.ai-team/team.md` for repos migrating from older installs) - **No** → Init Mode @@ -104,7 +105,7 @@ The `union` merge driver keeps all lines from both sides, which is correct for a **If you wrote code, generated artifacts, or produced domain work without dispatching to an agent, you violated this rule. The coordinator ROUTES — it does not BUILD. No exceptions.** -**On every session start:** Run `git config user.name` to identify the current user, and **resolve the team root** (see Worktree Awareness). Store the team root — all `.squad/` paths must be resolved relative to it. Pass the team root into every spawn prompt as `TEAM_ROOT` and the current user's name into every agent spawn prompt and Scribe log so the team always knows who requested the work. Check `.squad/identity/now.md` if it exists — it tells you what the team was last focused on. Update it if the focus has shifted. +**On every session start:** Run `git config user.name` to identify the current user, and **resolve the team root** (see Worktree Awareness). Store the team root — all `.squad/` paths must be resolved relative to it. Pass the team root and the current datetime (from `` in your system context) into every spawn prompt as `TEAM_ROOT` and `CURRENT_DATETIME` respectively. Pass the current user's name into every agent spawn prompt and Scribe log so the team always knows who requested the work. Check `.squad/identity/now.md` if it exists — it tells you what the team was last focused on. Update it if the focus has shifted. **⚡ Context caching:** After the first message in a session, `team.md`, `routing.md`, and `registry.json` are already in your context. Do NOT re-read them on subsequent messages — you already have the roster, routing rules, and cast names. Only re-read if the user explicitly modifies the team (adds/removes members, changes routing). @@ -330,6 +331,7 @@ description: "{emoji} {Name}: {brief task summary}" prompt: | You are {Name}, the {Role} on this project. TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} WORKTREE_PATH: {worktree_path} WORKTREE_MODE: {true|false} **Requested by:** {current user name} @@ -348,7 +350,7 @@ prompt: | ⚠️ RESPONSE ORDER: After ALL tool calls, write a plain text summary as FINAL output. ``` -For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. {question} TEAM ROOT: {team_root}"` +For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. CURRENT_DATETIME: {current_datetime} — {question} TEAM ROOT: {team_root}"` ### Per-Agent Model Selection @@ -499,7 +501,7 @@ The `sql` tool is **CLI-only**. It does not exist on VS Code, JetBrains, or GitH MCP (Model Context Protocol) servers extend Squad with tools for external services — Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them. -> **Full patterns:** Read `.squad/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, graceful degradation. Read `.squad/templates/mcp-config.md` for config file locations, sample configs, and authentication notes. +> **Config details:** Read `.squad/templates/mcp-config.md` for config file locations, sample configs, and authentication notes. #### Detection @@ -623,15 +625,17 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha **How the Coordinator resolves the team root (on every session start):** -1. Run `git rev-parse --show-toplevel` to get the current worktree root. -2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +1. **Check CWD first** — does `.squad/` exist in the current working directory? + - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. +2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. +3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: ``` git worktree list --porcelain ``` The first `worktree` line is the main working tree. Team root = that path. -3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. @@ -769,6 +773,7 @@ prompt: | {paste contents of .squad/agents/{name}/charter.md here} TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} All `.squad/` paths are relative to this root. PERSONAL_AGENT: {true|false} # Whether this is a personal agent @@ -815,6 +820,7 @@ prompt: | Do the work. Respond as {Name}. ⚠️ OUTPUT: Report outcomes in human terms. Never expose tool internals or SQL. + ⚠️ DATES: When writing dates in any file (decisions, history, logs), use ONLY the CURRENT_DATETIME value above. Never infer or guess the date. AFTER work: 1. APPEND to .squad/agents/{name}/history.md under "## Learnings": @@ -871,6 +877,7 @@ description: "📋 Scribe: Log session & merge decisions" prompt: | You are the Scribe. Read .squad/agents/scribe/charter.md. TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} SPAWN MANIFEST: {spawn_manifest} diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index 663fb74c1..1533a3421 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -8,7 +8,7 @@ * @module config/init */ -import { join, dirname } from 'path'; +import { join, dirname, relative as pathRelative, resolve as pathResolve } from 'path'; import { fileURLToPath } from 'url'; import type { StorageProvider } from '../storage/index.js'; import { FSStorageProvider } from '../storage/index.js'; @@ -136,6 +136,11 @@ export interface InitOptions { streams?: SubSquadDefinition[]; /** If true, use built-in base roles with useRole() in SDK config (default: false) */ roles?: boolean; + /** Root directory for the .github/agents/squad.agent.md file. + * Defaults to teamRoot. In monorepos, this should be the git root + * so Copilot can discover the agent file, while teamRoot stays in + * the subfolder where .squad/ lives. */ + agentFileRoot?: string; /** ADO work item configuration — used when platform is azure-devops */ adoConfig?: { defaultWorkItemType?: string; @@ -669,15 +674,10 @@ export async function initSquad(options: InitOptions, storage: StorageProvider = // Helper to convert absolute path to relative const toRelativePath = (absolutePath: string): string => { - // Use path separator-agnostic approach - if (absolutePath.startsWith(teamRoot)) { - const relative = absolutePath.slice(teamRoot.length); - // Remove leading separator if present - return relative.startsWith('/') || relative.startsWith('\\') - ? relative.slice(1) - : relative; - } - return absolutePath; + // Use path.relative for correct cross-root handling (monorepo: agentFileRoot != teamRoot) + const rel = pathRelative(teamRoot, absolutePath); + // path.relative returns '' for same path, use '.' instead + return rel || '.'; }; // Helper to write file (respects skipExisting) @@ -1062,7 +1062,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre // Create .github/agents/squad.agent.md // ------------------------------------------------------------------------- - const agentFile = join(teamRoot, '.github', 'agents', 'squad.agent.md'); + const agentFile = join(options.agentFileRoot ?? teamRoot, '.github', 'agents', 'squad.agent.md'); if (!storage.existsSync(agentFile) || !skipExisting) { if (templatesDir && storage.existsSync(join(templatesDir, 'squad.agent.md.template'))) { let agentContent = storage.readSync(join(templatesDir, 'squad.agent.md.template')) ?? ''; @@ -1110,6 +1110,15 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre // ------------------------------------------------------------------------- if (includeWorkflows && isGitHub && templatesDir && storage.existsSync(join(templatesDir, 'workflows'))) { + // In monorepo mode (agentFileRoot != teamRoot), skip workflow placement. + // GitHub Actions only reads from /.github/workflows/ — placing + // workflows in a subfolder has no effect. Multiple squads in one monorepo + // would also conflict on the same workflow files. (#939) + const isMonorepoSubfolder = !!options.agentFileRoot && + pathResolve(options.agentFileRoot).toLowerCase() !== pathResolve(teamRoot).toLowerCase(); + if (isMonorepoSubfolder) { + warnings.push('Skipped GitHub Actions workflows in monorepo-subfolder mode — workflows must be at the git root. Set up workflows manually or use a single shared workflow for all squads.'); + } else { const workflowsSrc = join(templatesDir, 'workflows'); const workflowsDest = join(teamRoot, '.github', 'workflows'); @@ -1128,6 +1137,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre } } } + } } // ------------------------------------------------------------------------- diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index f8410aecb..945b99dea 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -105,7 +105,7 @@ export * from './storage/index.js'; // Git-native state backends (Issue #807) export type { StateBackend, StateBackendType, StateBackendConfig } from './state-backend.js'; -export { WorktreeBackend, GitNotesBackend, OrphanBranchBackend, resolveStateBackend } from './state-backend.js'; +export { WorktreeBackend, GitNotesBackend, OrphanBranchBackend, resolveStateBackend, validateStateKey } from './state-backend.js'; // State facade (Phase 2) — namespaced to avoid conflicts with existing config/sharing exports export { diff --git a/packages/squad-sdk/src/platform/azure-devops.ts b/packages/squad-sdk/src/platform/azure-devops.ts index 7264f2121..279628b0e 100644 --- a/packages/squad-sdk/src/platform/azure-devops.ts +++ b/packages/squad-sdk/src/platform/azure-devops.ts @@ -4,10 +4,12 @@ * @module platform/azure-devops */ -import { execFileSync, execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import type { PlatformAdapter, PlatformType, WorkItem, PullRequest } from './types.js'; +const IS_WINDOWS = process.platform === 'win32'; const EXEC_OPTS: { encoding: 'utf-8'; stdio: ['pipe', 'pipe', 'pipe'] } = { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }; +const AZ_OPTS = { ...EXEC_OPTS, shell: IS_WINDOWS }; /** Descriptor for a work item type returned by process template introspection. */ export interface WorkItemTypeInfo { @@ -22,7 +24,7 @@ export interface WorkItemTypeInfo { /** Check whether the az CLI with devops extension is available */ function assertAzCliAvailable(): void { try { - execSync('az devops -h', EXEC_OPTS); + execFileSync('az', ['devops', '-h'], AZ_OPTS); } catch { throw new Error( 'Azure DevOps CLI not found. Install it with:\n' + @@ -67,7 +69,7 @@ export function getAvailableWorkItemTypes(org: string, project: string): WorkIte '--org', orgUrl, '--project', project, '--output', 'json', - ], { ...EXEC_OPTS, timeout: 3_000 }).trim(); + ], { ...AZ_OPTS, timeout: 3_000 }).trim(); const types = parseJson { @@ -433,7 +435,7 @@ export class AzureDevOpsAdapter implements PlatformAdapter { try { const orgUrl = `https://dev.azure.com/${targetOrg}`; - execFileSync('az', ['devops', 'configure', '--defaults', `organization=${orgUrl}`], EXEC_OPTS); + execFileSync('az', ['devops', 'configure', '--defaults', `organization=${orgUrl}`], AZ_OPTS); } catch { // az CLI might not be installed — non-fatal } diff --git a/packages/squad-sdk/src/platform/comms-teams.ts b/packages/squad-sdk/src/platform/comms-teams.ts index 608edaf6e..eb4c1a557 100644 --- a/packages/squad-sdk/src/platform/comms-teams.ts +++ b/packages/squad-sdk/src/platform/comms-teams.ts @@ -4,14 +4,14 @@ * Auth priority: cached token → refresh → browser PKCE → device code fallback. * Uses the Microsoft Graph PowerShell first-party client ID by default (works * in every Microsoft tenant with no custom Entra registration required). - * Tokens stored in ~/.squad/teams-tokens.json. + * Tokens stored per-identity in ~/.squad/teams-tokens-{hash(tenantId:clientId)}.json. * * @module platform/comms-teams */ import { join } from 'node:path'; import { homedir, platform } from 'node:os'; -import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs'; import { createServer } from 'node:http'; import { randomBytes, createHash } from 'node:crypto'; import { execFile } from 'node:child_process'; @@ -38,44 +38,131 @@ export interface TeamsCommsConfig { teamId?: string; } -// ─── Token Storage ─────────────────────────────────────────────────── +// ─── Token Storage (identity-scoped) ───────────────────────────────── interface StoredTokens { accessToken: string; refreshToken: string; expiresAt: number; // epoch ms + /** Configured tenant authority — validated on load to prevent cross-config reuse */ + configTenantId?: string; + /** Client ID (app registration) — validated on load */ + clientId?: string; + /** Actual tenant GUID from JWT `tid` claim — tracks real authenticated identity */ + authenticatedTenantId?: string; + /** Actual user object ID from JWT `oid` claim — tracks real authenticated identity */ + authenticatedUserId?: string; } const SQUAD_DIR = join(homedir(), '.squad'); -const TOKEN_PATH = join(SQUAD_DIR, 'teams-tokens.json'); +/** Legacy single-file path (pre-tenant-scoped) — migrated away on first use */ +const LEGACY_TOKEN_PATH = join(SQUAD_DIR, 'teams-tokens.json'); + +/** + * Derive a safe, collision-resistant filename for a token cache entry. + * Uses SHA-256 hash of `tenantId + clientId` to avoid path traversal, + * special character issues, and to provide collision-resistant separation for different app registrations. Uses 16 hex chars (~64 bits) of SHA-256 — sufficient for practical uniqueness across tenant/app combinations. + */ +function getTokenPath(tenantId: string, clientId: string): string { + const hash = createHash('sha256').update(`${tenantId}:${clientId}`).digest('hex').slice(0, 16); + return join(SQUAD_DIR, `teams-tokens-${hash}.json`); +} -function loadTokens(): StoredTokens | null { +function loadTokens(tenantId: string, clientId: string): StoredTokens | null { + const tokenPath = getTokenPath(tenantId, clientId); try { - if (!existsSync(TOKEN_PATH)) return null; - const raw = readFileSync(TOKEN_PATH, 'utf-8'); - return JSON.parse(raw) as StoredTokens; + if (!existsSync(tokenPath)) return null; + const raw = readFileSync(tokenPath, 'utf-8'); + const parsed = JSON.parse(raw) as StoredTokens; + // Validate minimum required shape + if (typeof parsed.accessToken !== 'string' || typeof parsed.expiresAt !== 'number' || typeof parsed.refreshToken !== 'string') return null; + // Reject tokens from a different config (stale/corrupted cache) + if (parsed.configTenantId && parsed.configTenantId !== tenantId) return null; + if (parsed.clientId && parsed.clientId !== clientId) return null; + return parsed; } catch { return null; } } // Security: tokens stored with 0o600 permissions (owner-only read/write). -// Consider OS keychain integration for production use. -function saveTokens(tokens: StoredTokens): void { +function saveTokens(tenantId: string, clientId: string, tokens: StoredTokens): void { + const tokenPath = getTokenPath(tenantId, clientId); if (!existsSync(SQUAD_DIR)) { mkdirSync(SQUAD_DIR, { recursive: true, mode: 0o700 }); } - writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2), { encoding: 'utf-8', mode: 0o600 }); + const jwtClaims = extractJwtClaims(tokens.accessToken); + const withMeta: StoredTokens = { + ...tokens, + configTenantId: tenantId, + clientId, + authenticatedTenantId: jwtClaims.tid, + authenticatedUserId: jwtClaims.oid, + }; + writeFileSync(tokenPath, JSON.stringify(withMeta, null, 2), { encoding: 'utf-8', mode: 0o600 }); // Ensure permissions are correct even if file already existed if (platform() === 'win32') { - execFile('icacls', [TOKEN_PATH, '/inheritance:r', '/grant:r', `${process.env.USERNAME ?? 'CURRENT_USER'}:(R,W)`], () => {}); + execFile('icacls', [tokenPath, '/inheritance:r', '/grant:r', `${process.env.USERNAME ?? 'CURRENT_USER'}:(R,W)`], (err) => { + if (err) console.warn('⚠️ Could not restrict token file permissions:', err.message); + }); } else { chmodSync(SQUAD_DIR, 0o700); - chmodSync(TOKEN_PATH, 0o600); + chmodSync(tokenPath, 0o600); + } +} + +/** + * Remove cached tokens from disk for a specific config. + * Used on permanent auth errors and explicit logout. + */ +function clearTokens(tenantId: string, clientId: string): void { + const tokenPath = getTokenPath(tenantId, clientId); + try { + if (existsSync(tokenPath)) unlinkSync(tokenPath); + } catch { /* best-effort cleanup */ } +} + +/** + * Migrate legacy single-file token cache to identity-scoped storage. + * Moves tokens from `teams-tokens.json` → `teams-tokens-{hash}.json`, + * then deletes the legacy file. + */ +function migrateLegacyTokens(tenantId: string, clientId: string): void { + try { + if (!existsSync(LEGACY_TOKEN_PATH)) return; + const raw = readFileSync(LEGACY_TOKEN_PATH, 'utf-8'); + const tokens = JSON.parse(raw) as StoredTokens; + if (tokens.accessToken) { + saveTokens(tenantId, clientId, tokens); + } + unlinkSync(LEGACY_TOKEN_PATH); + } catch { /* best-effort migration */ } +} + +/** + * Extract `tid` (tenant GUID) and `oid` (user object ID) from a JWT access token. + * Best-effort: returns empty object if the token can't be decoded. + * Does NOT verify the signature — the token was just received over TLS from Microsoft. + */ +function extractJwtClaims(accessToken: string): { tid?: string; oid?: string } { + try { + const parts = accessToken.split('.'); + if (parts.length !== 3 || !parts[1]) return {}; + const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const decoded = JSON.parse(Buffer.from(payload, 'base64').toString('utf-8')) as Record; + return { + tid: typeof decoded.tid === 'string' ? decoded.tid : undefined, + oid: typeof decoded.oid === 'string' ? decoded.oid : undefined, + }; + } catch { + return {}; } } +/** Errors that indicate a permanently invalid refresh token (do not retry) */ +const PERMANENT_AUTH_ERRORS = ['invalid_grant', 'interaction_required', 'consent_required', 'invalid_client']; + // ─── Graph Helpers ─────────────────────────────────────────────────── const GRAPH_BASE = 'https://graph.microsoft.com/v1.0'; @@ -225,7 +312,7 @@ async function startBrowserAuthFlow(tenantId: string, clientId: string): Promise throw new Error(`Token exchange failed: ${data.error} — ${data.error_description}`); } const tokens = parseTokens(data); - saveTokens(tokens); + saveTokens(tenantId, clientId, tokens); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(SUCCESS_HTML); @@ -289,6 +376,13 @@ interface DeviceCodeResponse { message: string; } +/** Maximum time allowed for device-code auth flow (15 minutes) */ +const DEVICE_CODE_TIMEOUT_MS = 15 * 60 * 1000; +/** Minimum poll interval for device-code flow (2 seconds) */ +const DEVICE_CODE_MIN_POLL_MS = 2_000; +/** Maximum poll interval for device-code flow (30 seconds) */ +const DEVICE_CODE_MAX_POLL_MS = 30_000; + async function startDeviceCodeFlow(tenantId: string, clientId: string): Promise { const deviceCodeUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/devicecode`; const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; @@ -309,8 +403,11 @@ async function startDeviceCodeFlow(tenantId: string, clientId: string): Promise< console.log(`\n🔐 Teams authentication required`); console.log(` ${dcData.message}\n`); - const pollInterval = (dcData.interval || 5) * 1000; - const deadline = Date.now() + dcData.expires_in * 1000; + // Clamp poll interval and timeout to sane bounds + const rawInterval = (dcData.interval || 5) * 1000; + const pollInterval = Math.max(DEVICE_CODE_MIN_POLL_MS, Math.min(rawInterval, DEVICE_CODE_MAX_POLL_MS)); + const serverTimeout = dcData.expires_in > 0 ? dcData.expires_in * 1000 : DEVICE_CODE_TIMEOUT_MS; + const deadline = Date.now() + Math.min(serverTimeout, DEVICE_CODE_TIMEOUT_MS); while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, pollInterval)); @@ -329,7 +426,7 @@ async function startDeviceCodeFlow(tenantId: string, clientId: string): Promise< if (tokenData.access_token) { const tokens = parseTokens(tokenData); - saveTokens(tokens); + saveTokens(tenantId, clientId, tokens); console.log(`✅ Teams authentication successful — tokens saved\n`); return tokens; } @@ -369,7 +466,10 @@ async function refreshAccessToken( const data = (await res.json()) as TokenResponse; if (!data.access_token) { - throw new Error(`Token refresh failed: ${data.error} — ${data.error_description}`); + const err = new Error(`Token refresh failed: ${data.error} — ${data.error_description}`); + // Attach error code for callers to distinguish permanent vs transient failures + (err as Error & { authError?: string }).authError = data.error ?? 'unknown'; + throw err; } const tokens: StoredTokens = { @@ -377,26 +477,10 @@ async function refreshAccessToken( refreshToken: data.refresh_token ?? refreshToken, expiresAt: Date.now() + data.expires_in * 1000, }; - saveTokens(tokens); + saveTokens(tenantId, clientId, tokens); return tokens; } -// ─── User ID Cache ─────────────────────────────────────────────────── - -let cachedUserId: string | null = null; - -async function getMyUserId(accessToken: string): Promise { - if (cachedUserId) return cachedUserId; - try { - const me = (await graphFetch(`${GRAPH_BASE}/me`, accessToken)) as { id: string }; - cachedUserId = me.id; - return cachedUserId; - } catch (err) { - console.warn(`⚠️ Teams /me fetch failed: ${(err as Error).message}`); - return null; - } -} - // ─── Adapter ───────────────────────────────────────────────────────── export class TeamsCommunicationAdapter implements CommunicationAdapter { @@ -406,11 +490,21 @@ export class TeamsCommunicationAdapter implements CommunicationAdapter { private resolvedChatId: string | null; private readonly clientId: string; private readonly tenantId: string; + /** Per-instance user ID cache — cleared on every token change to prevent cross-account leaks */ + private cachedUserId: string | null = null; constructor(private readonly config: TeamsCommsConfig) { this.resolvedChatId = config.chatId ?? null; this.clientId = config.clientId ?? DEFAULT_CLIENT_ID; this.tenantId = config.tenantId ?? DEFAULT_TENANT_ID; + // One-time migration from legacy single-file token storage + migrateLegacyTokens(this.tenantId, this.clientId); + } + + /** Reset all identity-sensitive caches. Called on every token change. */ + private resetIdentityCaches(): void { + this.cachedUserId = null; + this.resolvedChatId = this.config.chatId ?? null; } /** @@ -419,7 +513,12 @@ export class TeamsCommunicationAdapter implements CommunicationAdapter { */ private async ensureAuthenticated(): Promise { if (!this.tokens) { - this.tokens = loadTokens(); + this.tokens = loadTokens(this.tenantId, this.clientId); + if (this.tokens) { + // Loaded from disk — reset identity caches since this may be + // a different session or the identity may have changed + this.resetIdentityCaches(); + } } // Valid token — return it @@ -435,15 +534,26 @@ export class TeamsCommunicationAdapter implements CommunicationAdapter { this.clientId, this.tokens.refreshToken, ); + this.resetIdentityCaches(); return this.tokens.accessToken; - } catch { - console.warn('⚠️ Token refresh failed — re-authenticating...'); + } catch (err) { + const authError = (err as Error & { authError?: string }).authError ?? ''; + if (PERMANENT_AUTH_ERRORS.includes(authError)) { + // Permanent failure — clear stale tokens so they're not reloaded + clearTokens(this.tenantId, this.clientId); + this.tokens = null; + this.resetIdentityCaches(); + console.warn(`⚠️ Token refresh permanently failed (${authError}) — re-authenticating...`); + } else { + console.warn('⚠️ Token refresh failed (transient) — re-authenticating...'); + } } } // Try browser auth code flow with PKCE first try { this.tokens = await startBrowserAuthFlow(this.tenantId, this.clientId); + this.resetIdentityCaches(); console.log('✅ Teams authentication successful — tokens saved'); return this.tokens.accessToken; } catch { @@ -452,9 +562,34 @@ export class TeamsCommunicationAdapter implements CommunicationAdapter { // Fallback — device code flow (works in headless/SSH environments) this.tokens = await startDeviceCodeFlow(this.tenantId, this.clientId); + this.resetIdentityCaches(); return this.tokens.accessToken; } + /** + * Logout: clear cached credentials (memory + disk) for this adapter's config. + * This is a local credential purge — does not call Microsoft's revocation endpoint + * (public-client refresh tokens cannot be reliably revoked server-side). + */ + async logout(): Promise { + clearTokens(this.tenantId, this.clientId); + this.tokens = null; + this.resetIdentityCaches(); + } + + /** Resolve the current user's Graph ID, cached per auth session. */ + private async getMyUserId(accessToken: string): Promise { + if (this.cachedUserId) return this.cachedUserId; + try { + const me = (await graphFetch(`${GRAPH_BASE}/me`, accessToken)) as { id: string }; + this.cachedUserId = me.id; + return this.cachedUserId; + } catch (err) { + console.warn(`⚠️ Teams /me fetch failed: ${(err as Error).message}`); + return null; + } + } + /** * Find or create a 1:1 chat with the recipient. */ @@ -526,7 +661,7 @@ export class TeamsCommunicationAdapter implements CommunicationAdapter { // Return stable composite ID so pollForReplies can locate the channel return { id: `${this.config.teamId}|${this.config.channelId}`, - url: `https://teams.microsoft.com/l/channel/${this.config.channelId}`, + url: `https://teams.microsoft.com/l/channel/${encodeURIComponent(this.config.channelId)}`, }; } @@ -592,7 +727,7 @@ export class TeamsCommunicationAdapter implements CommunicationAdapter { return []; } - const myId = await getMyUserId(accessToken); + const myId = await this.getMyUserId(accessToken); return data.value .filter((m) => { @@ -640,4 +775,9 @@ function stripHtml(html: string): string { return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').trim(); } -export { escapeHtml, stripHtml, formatTeamsMessage, parseTokens, base64url, validateGraphId }; +export { + escapeHtml, stripHtml, formatTeamsMessage, parseTokens, base64url, validateGraphId, + getTokenPath, clearTokens, loadTokens, saveTokens, migrateLegacyTokens, extractJwtClaims, + DEVICE_CODE_TIMEOUT_MS, DEVICE_CODE_MIN_POLL_MS, DEVICE_CODE_MAX_POLL_MS, + LEGACY_TOKEN_PATH, PERMANENT_AUTH_ERRORS, +}; diff --git a/packages/squad-sdk/src/platform/types.ts b/packages/squad-sdk/src/platform/types.ts index 39d7599d4..ae33ea0d2 100644 --- a/packages/squad-sdk/src/platform/types.ts +++ b/packages/squad-sdk/src/platform/types.ts @@ -140,4 +140,11 @@ export interface CommunicationAdapter { * Returns undefined if the channel has no web UI (e.g., file-log). */ getNotificationUrl(threadId: string): string | undefined; + + /** + * Logout: clear cached credentials for this adapter. + * Local credential purge — does not revoke server-side tokens. + * Optional — not all adapters require authentication. + */ + logout?(): Promise; } diff --git a/packages/squad-sdk/src/runtime/scheduler.ts b/packages/squad-sdk/src/runtime/scheduler.ts index d16e5db58..4addb8c31 100644 --- a/packages/squad-sdk/src/runtime/scheduler.ts +++ b/packages/squad-sdk/src/runtime/scheduler.ts @@ -204,6 +204,9 @@ function validateEntry(entry: unknown, index: number, seenIds: Set): voi if (typeof task.ref !== 'string' || task.ref.length === 0) { throw new ScheduleValidationError(`${prefix}.task.ref must be a non-empty string`); } + if (task.type === 'script') { + validateTaskRef(task.ref as string); + } // Providers validation if (!Array.isArray(e.providers) || e.providers.length === 0) { @@ -359,6 +362,23 @@ function cronFieldMatches(field: string, value: number): boolean { return values.includes(value); } +/** + * Validate a task ref for safety. Rejects null bytes and newlines which + * can cause issues even without shell interpretation. + * The structural protection comes from execFileSync (shell: false). + */ +export function validateTaskRef(ref: string): void { + if (!ref || ref.trim().length === 0) { + throw new ScheduleValidationError('Task ref must be a non-empty string'); + } + if (ref.includes('\0')) { + throw new ScheduleValidationError('Task ref must not contain null bytes'); + } + if (/[\r\n]/.test(ref)) { + throw new ScheduleValidationError('Task ref must not contain newline characters'); + } +} + // ============================================================================ // Task Execution // ============================================================================ @@ -430,8 +450,12 @@ export class LocalPollingProvider implements ScheduleProvider { switch (entry.task.type) { case 'script': { try { - const { execSync } = await import('node:child_process'); - const output = execSync(entry.task.ref, { + const { execFileSync } = await import('node:child_process'); + validateTaskRef(entry.task.ref); + const argv = entry.task.ref.trim().split(/\s+/); + const command = argv[0]!; + const args = argv.slice(1); + const output = execFileSync(command, args, { encoding: 'utf8', timeout: 60_000, }); diff --git a/packages/squad-sdk/src/state-backend.ts b/packages/squad-sdk/src/state-backend.ts index 921adf72a..32e36bac6 100644 --- a/packages/squad-sdk/src/state-backend.ts +++ b/packages/squad-sdk/src/state-backend.ts @@ -4,7 +4,7 @@ * @module state-backend */ -import { execSync, execFileSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import path from 'node:path'; import { FSStorageProvider } from './storage/fs-storage-provider.js'; @@ -28,41 +28,76 @@ export class WorktreeBackend implements StateBackend { this.root = squadDir; } read(relativePath: string): string | undefined { - return storage.readSync(path.join(this.root, relativePath)) ?? undefined; + const key = normalizeKey(relativePath); + return storage.readSync(path.join(this.root, key)) ?? undefined; } write(relativePath: string, content: string): void { - storage.writeSync(path.join(this.root, relativePath), content); + const key = normalizeKey(relativePath); + storage.writeSync(path.join(this.root, key), content); } exists(relativePath: string): boolean { - return storage.existsSync(path.join(this.root, relativePath)); + const key = normalizeKey(relativePath); + return storage.existsSync(path.join(this.root, key)); } list(relativeDir: string): string[] { - const full = path.join(this.root, relativeDir); + const key = normalizeKey(relativeDir); + const full = path.join(this.root, key); if (!storage.existsSync(full) || !storage.isDirectorySync(full)) return []; return storage.listSync(full); } } -function gitExec(args: string, cwd: string): string | null { +function gitExec(args: string[], cwd: string): string | null { try { - return execFileSync('git', args.split(' '), { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); } catch { return null; } } -function gitExecContent(args: string, cwd: string): string | null { - try { - return execFileSync('git', args.split(' '), { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trimEnd(); - } catch { return null; } +function gitExecWithInput(args: string[], input: string, cwd: string): string { + return execFileSync('git', args, { cwd, input, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); } -function gitExecOrThrow(args: string, cwd: string): string { +function gitExecOrThrow(args: string[], cwd: string): string { const result = gitExec(args, cwd); - if (result === null) throw new Error(`git command failed: git ${args}`); + if (result === null) throw new Error(`git command failed: git ${args.join(' ')}`); return result; } +/** + * Validate a state key against characters that could corrupt git plumbing + * input (mktree stdin format, branch:path refs) or cause path confusion. + */ +export function validateStateKey(key: string): void { + if (!key || key.length === 0) { + throw new Error('State key must be non-empty'); + } + if (key.includes('\0')) { + throw new Error('State key must not contain null bytes'); + } + if (/[\n\r]/.test(key)) { + throw new Error('State key must not contain newline characters'); + } + if (key.includes('\t')) { + throw new Error('State key must not contain tab characters'); + } + const segments = key.split('/'); + for (const seg of segments) { + if (seg === '') { + throw new Error('State key must not contain empty path segments'); + } + if (seg === '.' || seg === '..') { + throw new Error('State key must not contain . or .. path segments'); + } + } +} + function normalizeKey(relativePath: string): string { - return relativePath.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); + const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); + // Empty string after normalization means "root" — valid for list() operations + if (normalized.length > 0) { + validateStateKey(normalized); + } + return normalized; } export class GitNotesBackend implements StateBackend { @@ -72,7 +107,7 @@ export class GitNotesBackend implements StateBackend { constructor(repoRoot: string) { this.cwd = repoRoot; } private loadBlob(): Record { - const raw = gitExec(`notes --ref=${this.ref} show HEAD`, this.cwd); + const raw = gitExec(['notes', `--ref=${this.ref}`, 'show', 'HEAD'], this.cwd); if (!raw) return {}; try { const parsed: unknown = JSON.parse(raw); @@ -86,9 +121,7 @@ export class GitNotesBackend implements StateBackend { private saveBlob(blob: Record): void { const json = JSON.stringify(blob, null, 2); try { - execSync(`git notes --ref=${this.ref} add -f --file - HEAD`, { - cwd: this.cwd, input: json, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - }); + gitExecWithInput(['notes', `--ref=${this.ref}`, 'add', '-f', '--file', '-', 'HEAD'], json, this.cwd); } catch { throw new Error('git-notes backend: failed to write note on HEAD'); } } @@ -129,22 +162,22 @@ export class OrphanBranchBackend implements StateBackend { } private ensureBranch(): void { - if (gitExec(`rev-parse --verify refs/heads/${this.branch}`, this.cwd)) return; + if (gitExec(['rev-parse', '--verify', `refs/heads/${this.branch}`], this.cwd)) return; let tree: string; try { - tree = execSync('git mktree', { cwd: this.cwd, input: '', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + tree = gitExecWithInput(['mktree'], '', this.cwd); } catch { throw new Error('orphan backend: failed to create empty tree'); } let commit: string; try { - commit = execSync(`git commit-tree ${tree} -m "Initialize squad-state branch"`, { + commit = execFileSync('git', ['commit-tree', tree, '-m', 'Initialize squad-state branch'], { cwd: this.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); } catch { throw new Error('orphan backend: failed to create initial commit'); } - gitExecOrThrow(`update-ref refs/heads/${this.branch} ${commit}`, this.cwd); + gitExecOrThrow(['update-ref', `refs/heads/${this.branch}`, commit], this.cwd); } read(relativePath: string): string | undefined { - const result = gitExec(`show ${this.branch}:${normalizeKey(relativePath)}`, this.cwd); + const result = gitExec(['show', `${this.branch}:${normalizeKey(relativePath)}`], this.cwd); return result ?? undefined; } @@ -153,39 +186,37 @@ export class OrphanBranchBackend implements StateBackend { const key = normalizeKey(relativePath); let blobHash: string; try { - blobHash = execSync('git hash-object -w --stdin', { - cwd: this.cwd, input: content, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); + blobHash = gitExecWithInput(['hash-object', '-w', '--stdin'], content, this.cwd); } catch { throw new Error(`orphan backend: failed to hash content for ${key}`); } let currentTree: string; - const treeResult = gitExec(`log --format=%T -1 ${this.branch}`, this.cwd); + const treeResult = gitExec(['log', '--format=%T', '-1', this.branch], this.cwd); if (!treeResult) { try { - currentTree = execSync('git mktree', { cwd: this.cwd, input: '', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + currentTree = gitExecWithInput(['mktree'], '', this.cwd); } catch { throw new Error('orphan backend: failed to create empty tree'); } } else { currentTree = treeResult; } const newTree = this.updateTree(currentTree, key.split('/'), blobHash); - const parentCommit = gitExec(`rev-parse ${this.branch}`, this.cwd); + const parentCommit = gitExec(['rev-parse', this.branch], this.cwd); let newCommit: string; try { - const parentArg = parentCommit ? `-p ${parentCommit}` : ''; - newCommit = execSync(`git commit-tree ${newTree} ${parentArg} -m "Update ${key}"`, { + const parentArgs = parentCommit ? ['-p', parentCommit] : []; + newCommit = execFileSync('git', ['commit-tree', newTree, ...parentArgs, '-m', `Update ${key}`], { cwd: this.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); } catch { throw new Error(`orphan backend: failed to commit update for ${key}`); } - gitExecOrThrow(`update-ref refs/heads/${this.branch} ${newCommit}`, this.cwd); + gitExecOrThrow(['update-ref', `refs/heads/${this.branch}`, newCommit], this.cwd); } exists(relativePath: string): boolean { - return gitExec(`cat-file -t ${this.branch}:${normalizeKey(relativePath)}`, this.cwd) !== null; + return gitExec(['cat-file', '-t', `${this.branch}:${normalizeKey(relativePath)}`], this.cwd) !== null; } list(relativeDir: string): string[] { const key = normalizeKey(relativeDir); const target = key ? `${this.branch}:${key}` : `${this.branch}:`; - const result = gitExec(`ls-tree --name-only ${target}`, this.cwd); + const result = gitExec(['ls-tree', '--name-only', target], this.cwd); if (!result) return []; return result.split('\n').filter(Boolean); } @@ -201,14 +232,14 @@ export class OrphanBranchBackend implements StateBackend { if (subTreeHash) { childTree = this.updateTree(subTreeHash, rest, blobHash); } else { - const emptyTree = execSync('git mktree', { cwd: this.cwd, input: '', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + const emptyTree = gitExecWithInput(['mktree'], '', this.cwd); childTree = this.updateTree(emptyTree, rest, blobHash); } return this.replaceEntry(baseTree, dir!, '040000', 'tree', childTree); } private getSubtreeHash(treeHash: string, name: string): string | null { - const listing = gitExec(`ls-tree ${treeHash}`, this.cwd); + const listing = gitExec(['ls-tree', treeHash], this.cwd); if (!listing) return null; for (const line of listing.split('\n')) { const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\t(.+)$/); @@ -218,7 +249,7 @@ export class OrphanBranchBackend implements StateBackend { } private replaceEntry(treeHash: string, name: string, mode: string, type: string, hash: string): string { - const listing = gitExec(`ls-tree ${treeHash}`, this.cwd) ?? ''; + const listing = gitExec(['ls-tree', treeHash], this.cwd) ?? ''; const lines = listing.split('\n').filter(Boolean); const filtered = lines.filter((line) => { const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\t(.+)$/); @@ -226,7 +257,7 @@ export class OrphanBranchBackend implements StateBackend { }); filtered.push(`${mode} ${type} ${hash}\t${name}`); try { - return execSync('git mktree', { cwd: this.cwd, input: filtered.join('\n') + '\n', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + return gitExecWithInput(['mktree'], filtered.join('\n') + '\n', this.cwd); } catch { throw new Error(`orphan backend: failed to create tree with entry ${name}`); } } } diff --git a/packages/squad-sdk/templates/mcp-config.md b/packages/squad-sdk/templates/mcp-config.md index 2e361ee4b..f38425e4c 100644 --- a/packages/squad-sdk/templates/mcp-config.md +++ b/packages/squad-sdk/templates/mcp-config.md @@ -2,8 +2,6 @@ MCP (Model Context Protocol) servers extend Squad with tools for external services — Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them. -> **Full patterns:** Read `.squad/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, and graceful degradation. - ## Config File Locations Users configure MCP servers at these locations (checked in priority order): diff --git a/packages/squad-sdk/templates/scribe-charter.md b/packages/squad-sdk/templates/scribe-charter.md index 78d4d971b..308b780e2 100644 --- a/packages/squad-sdk/templates/scribe-charter.md +++ b/packages/squad-sdk/templates/scribe-charter.md @@ -43,7 +43,7 @@ After every substantial work session: - **Exact duplicates:** If two blocks share the same heading, keep the first and remove the rest. - **Overlapping decisions:** Compare block content across all remaining blocks. If two or more blocks cover the same area (same topic, same architectural concern, same component) but were written independently (different dates, different authors), consolidate them: a. Synthesize a single merged block that combines the intent and rationale from all overlapping blocks. - b. Use today's date and a new heading: `### {today}: {consolidated topic} (consolidated)` + b. Use the CURRENT_DATETIME value from your spawn prompt and a new heading: `### {CURRENT_DATETIME}: {consolidated topic} (consolidated)` c. Credit all original authors: `**By:** {Name1}, {Name2}` d. Under **What:**, combine the decisions. Note any differences or evolution. e. Under **Why:**, merge the rationale, preserving unique reasoning from each. diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index 1a3813a33..01e18dfad 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -19,6 +19,7 @@ You are **Squad (Coordinator)** — the orchestrator for this project's AI team. - You may NOT generate domain artifacts (code, designs, analyses) — spawn an agent - You may NOT bypass reviewer approval on rejected work - You may NOT invent facts or assumptions — ask the user or spawn an agent who knows + - You may NOT do work yourself — ALWAYS delegate to a team member, even for small tasks. The only exception is Direct Mode (status checks, factual questions, and simple answers from context — see Response Mode Selection). Check: Does `.squad/team.md` exist? (fall back to `.ai-team/team.md` for repos migrating from older installs) - **No** → Init Mode @@ -104,7 +105,7 @@ The `union` merge driver keeps all lines from both sides, which is correct for a **If you wrote code, generated artifacts, or produced domain work without dispatching to an agent, you violated this rule. The coordinator ROUTES — it does not BUILD. No exceptions.** -**On every session start:** Run `git config user.name` to identify the current user, and **resolve the team root** (see Worktree Awareness). Store the team root — all `.squad/` paths must be resolved relative to it. Pass the team root into every spawn prompt as `TEAM_ROOT` and the current user's name into every agent spawn prompt and Scribe log so the team always knows who requested the work. Check `.squad/identity/now.md` if it exists — it tells you what the team was last focused on. Update it if the focus has shifted. +**On every session start:** Run `git config user.name` to identify the current user, and **resolve the team root** (see Worktree Awareness). Store the team root — all `.squad/` paths must be resolved relative to it. Pass the team root and the current datetime (from `` in your system context) into every spawn prompt as `TEAM_ROOT` and `CURRENT_DATETIME` respectively. Pass the current user's name into every agent spawn prompt and Scribe log so the team always knows who requested the work. Check `.squad/identity/now.md` if it exists — it tells you what the team was last focused on. Update it if the focus has shifted. **⚡ Context caching:** After the first message in a session, `team.md`, `routing.md`, and `registry.json` are already in your context. Do NOT re-read them on subsequent messages — you already have the roster, routing rules, and cast names. Only re-read if the user explicitly modifies the team (adds/removes members, changes routing). @@ -330,6 +331,7 @@ description: "{emoji} {Name}: {brief task summary}" prompt: | You are {Name}, the {Role} on this project. TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} WORKTREE_PATH: {worktree_path} WORKTREE_MODE: {true|false} **Requested by:** {current user name} @@ -348,7 +350,7 @@ prompt: | ⚠️ RESPONSE ORDER: After ALL tool calls, write a plain text summary as FINAL output. ``` -For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. {question} TEAM ROOT: {team_root}"` +For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. CURRENT_DATETIME: {current_datetime} — {question} TEAM ROOT: {team_root}"` ### Per-Agent Model Selection @@ -499,7 +501,7 @@ The `sql` tool is **CLI-only**. It does not exist on VS Code, JetBrains, or GitH MCP (Model Context Protocol) servers extend Squad with tools for external services — Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them. -> **Full patterns:** Read `.squad/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, graceful degradation. Read `.squad/templates/mcp-config.md` for config file locations, sample configs, and authentication notes. +> **Config details:** Read `.squad/templates/mcp-config.md` for config file locations, sample configs, and authentication notes. #### Detection @@ -623,15 +625,17 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha **How the Coordinator resolves the team root (on every session start):** -1. Run `git rev-parse --show-toplevel` to get the current worktree root. -2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +1. **Check CWD first** — does `.squad/` exist in the current working directory? + - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. +2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. +3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: ``` git worktree list --porcelain ``` The first `worktree` line is the main working tree. Team root = that path. -3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. @@ -769,6 +773,7 @@ prompt: | {paste contents of .squad/agents/{name}/charter.md here} TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} All `.squad/` paths are relative to this root. PERSONAL_AGENT: {true|false} # Whether this is a personal agent @@ -815,6 +820,7 @@ prompt: | Do the work. Respond as {Name}. ⚠️ OUTPUT: Report outcomes in human terms. Never expose tool internals or SQL. + ⚠️ DATES: When writing dates in any file (decisions, history, logs), use ONLY the CURRENT_DATETIME value above. Never infer or guess the date. AFTER work: 1. APPEND to .squad/agents/{name}/history.md under "## Learnings": @@ -871,6 +877,7 @@ description: "📋 Scribe: Log session & merge decisions" prompt: | You are the Scribe. Read .squad/agents/scribe/charter.md. TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} SPAWN MANIFEST: {spawn_manifest} diff --git a/templates/mcp-config.md b/templates/mcp-config.md index 2e361ee4b..f38425e4c 100644 --- a/templates/mcp-config.md +++ b/templates/mcp-config.md @@ -2,8 +2,6 @@ MCP (Model Context Protocol) servers extend Squad with tools for external services — Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them. -> **Full patterns:** Read `.squad/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, and graceful degradation. - ## Config File Locations Users configure MCP servers at these locations (checked in priority order): diff --git a/templates/scribe-charter.md b/templates/scribe-charter.md index 78d4d971b..308b780e2 100644 --- a/templates/scribe-charter.md +++ b/templates/scribe-charter.md @@ -43,7 +43,7 @@ After every substantial work session: - **Exact duplicates:** If two blocks share the same heading, keep the first and remove the rest. - **Overlapping decisions:** Compare block content across all remaining blocks. If two or more blocks cover the same area (same topic, same architectural concern, same component) but were written independently (different dates, different authors), consolidate them: a. Synthesize a single merged block that combines the intent and rationale from all overlapping blocks. - b. Use today's date and a new heading: `### {today}: {consolidated topic} (consolidated)` + b. Use the CURRENT_DATETIME value from your spawn prompt and a new heading: `### {CURRENT_DATETIME}: {consolidated topic} (consolidated)` c. Credit all original authors: `**By:** {Name1}, {Name2}` d. Under **What:**, combine the decisions. Note any differences or evolution. e. Under **Why:**, merge the rationale, preserving unique reasoning from each. diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index 1a3813a33..01e18dfad 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -19,6 +19,7 @@ You are **Squad (Coordinator)** — the orchestrator for this project's AI team. - You may NOT generate domain artifacts (code, designs, analyses) — spawn an agent - You may NOT bypass reviewer approval on rejected work - You may NOT invent facts or assumptions — ask the user or spawn an agent who knows + - You may NOT do work yourself — ALWAYS delegate to a team member, even for small tasks. The only exception is Direct Mode (status checks, factual questions, and simple answers from context — see Response Mode Selection). Check: Does `.squad/team.md` exist? (fall back to `.ai-team/team.md` for repos migrating from older installs) - **No** → Init Mode @@ -104,7 +105,7 @@ The `union` merge driver keeps all lines from both sides, which is correct for a **If you wrote code, generated artifacts, or produced domain work without dispatching to an agent, you violated this rule. The coordinator ROUTES — it does not BUILD. No exceptions.** -**On every session start:** Run `git config user.name` to identify the current user, and **resolve the team root** (see Worktree Awareness). Store the team root — all `.squad/` paths must be resolved relative to it. Pass the team root into every spawn prompt as `TEAM_ROOT` and the current user's name into every agent spawn prompt and Scribe log so the team always knows who requested the work. Check `.squad/identity/now.md` if it exists — it tells you what the team was last focused on. Update it if the focus has shifted. +**On every session start:** Run `git config user.name` to identify the current user, and **resolve the team root** (see Worktree Awareness). Store the team root — all `.squad/` paths must be resolved relative to it. Pass the team root and the current datetime (from `` in your system context) into every spawn prompt as `TEAM_ROOT` and `CURRENT_DATETIME` respectively. Pass the current user's name into every agent spawn prompt and Scribe log so the team always knows who requested the work. Check `.squad/identity/now.md` if it exists — it tells you what the team was last focused on. Update it if the focus has shifted. **⚡ Context caching:** After the first message in a session, `team.md`, `routing.md`, and `registry.json` are already in your context. Do NOT re-read them on subsequent messages — you already have the roster, routing rules, and cast names. Only re-read if the user explicitly modifies the team (adds/removes members, changes routing). @@ -330,6 +331,7 @@ description: "{emoji} {Name}: {brief task summary}" prompt: | You are {Name}, the {Role} on this project. TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} WORKTREE_PATH: {worktree_path} WORKTREE_MODE: {true|false} **Requested by:** {current user name} @@ -348,7 +350,7 @@ prompt: | ⚠️ RESPONSE ORDER: After ALL tool calls, write a plain text summary as FINAL output. ``` -For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. {question} TEAM ROOT: {team_root}"` +For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. CURRENT_DATETIME: {current_datetime} — {question} TEAM ROOT: {team_root}"` ### Per-Agent Model Selection @@ -499,7 +501,7 @@ The `sql` tool is **CLI-only**. It does not exist on VS Code, JetBrains, or GitH MCP (Model Context Protocol) servers extend Squad with tools for external services — Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them. -> **Full patterns:** Read `.squad/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, graceful degradation. Read `.squad/templates/mcp-config.md` for config file locations, sample configs, and authentication notes. +> **Config details:** Read `.squad/templates/mcp-config.md` for config file locations, sample configs, and authentication notes. #### Detection @@ -623,15 +625,17 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha **How the Coordinator resolves the team root (on every session start):** -1. Run `git rev-parse --show-toplevel` to get the current worktree root. -2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +1. **Check CWD first** — does `.squad/` exist in the current working directory? + - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. +2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. +3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: ``` git worktree list --porcelain ``` The first `worktree` line is the main working tree. Team root = that path. -3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. @@ -769,6 +773,7 @@ prompt: | {paste contents of .squad/agents/{name}/charter.md here} TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} All `.squad/` paths are relative to this root. PERSONAL_AGENT: {true|false} # Whether this is a personal agent @@ -815,6 +820,7 @@ prompt: | Do the work. Respond as {Name}. ⚠️ OUTPUT: Report outcomes in human terms. Never expose tool internals or SQL. + ⚠️ DATES: When writing dates in any file (decisions, history, logs), use ONLY the CURRENT_DATETIME value above. Never infer or guess the date. AFTER work: 1. APPEND to .squad/agents/{name}/history.md under "## Learnings": @@ -871,6 +877,7 @@ description: "📋 Scribe: Log session & merge decisions" prompt: | You are the Scribe. Read .squad/agents/scribe/charter.md. TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} SPAWN MANIFEST: {spawn_manifest} diff --git a/test-fixtures/init-test/.gitignore b/test-fixtures/init-test/.gitignore new file mode 100644 index 000000000..bede03212 --- /dev/null +++ b/test-fixtures/init-test/.gitignore @@ -0,0 +1,2 @@ +# Squad: SubSquad activation file (local to this machine) +.squad-workstream diff --git a/test/aspire-integration.test.ts b/test/aspire-integration.test.ts index d54d5a99e..e654b73e2 100644 --- a/test/aspire-integration.test.ts +++ b/test/aspire-integration.test.ts @@ -15,6 +15,7 @@ import { trace, metrics } from '@opentelemetry/api'; import { NodeSDK, resources, metrics as sdkMetrics } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc'; +import { dockerSkipReason } from './helpers/skip-guards.js'; const { Resource } = resources; const { PeriodicExportingMetricReader } = sdkMetrics; @@ -23,20 +24,7 @@ const { PeriodicExportingMetricReader } = sdkMetrics; // Skip guard — bail early if Docker is unavailable or tests disabled // ============================================================================ -function dockerAvailable(): boolean { - try { - execSync('docker --version', { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -const SKIP_REASON = process.env['SKIP_DOCKER_TESTS'] === '1' - ? 'SKIP_DOCKER_TESTS=1' - : !dockerAvailable() - ? 'Docker not available' - : null; +const SKIP_REASON = dockerSkipReason(); const CONTAINER_NAME = 'squad-aspire-dashboard'; const DASHBOARD_URL = 'http://localhost:18888'; @@ -71,14 +59,17 @@ function removeContainer(): void { } // Best-effort cleanup on unexpected exit (Ctrl+C, uncaught exception, etc.) -// This prevents orphaned containers when the test runner is interrupted. -for (const signal of ['SIGINT', 'SIGTERM'] as const) { - process.once(signal, () => { - removeContainer(); - process.exit(128 + (signal === 'SIGINT' ? 2 : 15)); - }); +// Only register handlers when Docker tests will actually run — avoids +// Docker side effects (and stale removeContainer calls) in skip mode. +if (SKIP_REASON === null) { + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + removeContainer(); + process.exit(128 + (signal === 'SIGINT' ? 2 : 15)); + }); + } + process.once('exit', () => removeContainer()); } -process.once('exit', () => removeContainer()); // ============================================================================ // OTel setup — NodeSDK with gRPC exporters targeting the Aspire dashboard diff --git a/test/cli-packaging-smoke.test.ts b/test/cli-packaging-smoke.test.ts index 127aba870..4fb8b5e73 100644 --- a/test/cli-packaging-smoke.test.ts +++ b/test/cli-packaging-smoke.test.ts @@ -11,16 +11,36 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { mkdtempSync, rmSync, existsSync, readFileSync, renameSync } from 'node:fs'; import { join } from 'node:path'; -import { execSync } from 'node:child_process'; +import { execSync, execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; +const NO_COLOR_ENV = { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + npm_config_loglevel: process.env['npm_config_loglevel'] ?? 'warn', +}; +const MISSING_MODULE_RE = /MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|Cannot find module|Cannot find package/i; + +interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; + timedOut?: boolean; +} + +interface InstalledCli { + tempDir: string; + cliEntryPath: string; +} + describe('CLI packaging smoke test', { timeout: 120_000 }, () => { - let tempDir: string; + let packageArtifactsDir: string | undefined; + let installedCli: InstalledCli | undefined; let sdkTarball: string; let cliTarball: string; - let cliEntryPath: string; beforeAll(() => { const cwd = process.cwd(); @@ -45,53 +65,56 @@ describe('CLI packaging smoke test', { timeout: 120_000 }, () => { execSync('npm run build', { cwd: cliDir, stdio: 'inherit', env: buildEnv }); } - // Pack both packages - console.log('Packing squad-sdk...'); - const sdkPackOutput = execSync('npm pack --quiet', { + packageArtifactsDir = mkdtempSync(join(tmpdir(), 'squad-cli-pack-')); + + // Pack both packages into an isolated temp directory so reruns do not + // reuse or mutate repo-local tarballs between installs. + const sdkPackOutput = execSync(`npm pack --quiet --pack-destination "${packageArtifactsDir}"`, { cwd: sdkDir, encoding: 'utf8', - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + env: NO_COLOR_ENV, }).trim(); - sdkTarball = join(sdkDir, sdkPackOutput.split('\n').pop()!.trim()); + sdkTarball = join(packageArtifactsDir, sdkPackOutput.split('\n').pop()!.trim()); - console.log('Packing squad-cli...'); - const cliPackOutput = execSync('npm pack --quiet', { + const cliPackOutput = execSync(`npm pack --quiet --pack-destination "${packageArtifactsDir}"`, { cwd: cliDir, encoding: 'utf8', - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + env: NO_COLOR_ENV, }).trim(); - cliTarball = join(cliDir, cliPackOutput.split('\n').pop()!.trim()); + cliTarball = join(packageArtifactsDir, cliPackOutput.split('\n').pop()!.trim()); - // Create temp directory and install - tempDir = mkdtempSync(join(tmpdir(), 'squad-cli-test-')); - console.log(`Installing packages in ${tempDir}...`); + const installPackedCli = (prefix: string): InstalledCli => { + const tempDir = mkdtempSync(join(tmpdir(), prefix)); - // Initialize package.json - execSync('npm init -y', { - cwd: tempDir, - stdio: 'ignore', - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, - }); + execSync('npm init -y', { + cwd: tempDir, + stdio: 'ignore', + env: NO_COLOR_ENV, + }); - // Install both tarballs - execSync(`npm install "${sdkTarball}" "${cliTarball}"`, { - cwd: tempDir, - stdio: 'inherit', - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, - }); + execSync(`npm install "${sdkTarball}" "${cliTarball}"`, { + cwd: tempDir, + stdio: 'inherit', + env: NO_COLOR_ENV, + }); - cliEntryPath = join( - tempDir, - 'node_modules', - '@bradygaster', - 'squad-cli', - 'dist', - 'cli-entry.js', - ); - - if (!existsSync(cliEntryPath)) { - throw new Error(`CLI entry point not found at ${cliEntryPath}`); - } + const cliEntryPath = join( + tempDir, + 'node_modules', + '@bradygaster', + 'squad-cli', + 'dist', + 'cli-entry.js', + ); + + if (!existsSync(cliEntryPath)) { + throw new Error(`CLI entry point not found at ${cliEntryPath}`); + } + + return { tempDir, cliEntryPath }; + }; + + installedCli = installPackedCli('squad-cli-test-'); }, 90000); afterAll(() => { @@ -118,9 +141,8 @@ describe('CLI packaging smoke test', { timeout: 120_000 }, () => { } }; - cleanupWithRetry(tempDir); - cleanupWithRetry(sdkTarball); - cleanupWithRetry(cliTarball); + cleanupWithRetry(installedCli?.tempDir ?? ''); + cleanupWithRetry(packageArtifactsDir ?? ''); }); /** @@ -133,13 +155,17 @@ describe('CLI packaging smoke test', { timeout: 120_000 }, () => { * and `start` hang waiting for infrastructure; a timeout means they were * routed successfully. */ - function runCommand(args: string[]): { stdout: string; stderr: string; exitCode: number; timedOut?: boolean } { + function runCommand(args: string[], cli = installedCli): CommandResult { + if (!cli) { + throw new Error('CLI package is not installed for this test'); + } + try { - const stdout = execSync(`node "${cliEntryPath}" ${args.join(' ')}`, { - cwd: tempDir, + const stdout = execFileSync(process.execPath, [cli.cliEntryPath, ...args], { + cwd: cli.tempDir, encoding: 'utf8', timeout: 2000, - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + env: NO_COLOR_ENV, }); return { stdout, stderr: '', exitCode: 0 }; } catch (err: any) { @@ -163,29 +189,28 @@ describe('CLI packaging smoke test', { timeout: 120_000 }, () => { /** * Helper to verify a command was routed (not unknown, not module error). - * Some commands may have optional dependencies (e.g., node-pty for 'start') - * that aren't packaged. We still consider them routed if the error is about - * a missing optional dependency, not the command itself being unknown. */ - function expectCommandRouted(result: { stdout: string; stderr: string; timedOut?: boolean }, command?: string) { + function expectCommandRouted(result: { stdout: string; stderr: string; timedOut?: boolean }) { // If the command timed out, it was routed — it started executing // but hung waiting for infrastructure (e.g., rc, start, aspire) if (result.timedOut) return; const output = result.stdout + result.stderr; expect(output.toLowerCase()).not.toContain('unknown command'); - - // Allow MODULE_NOT_FOUND if it's for an optional dependency (node-pty), - // not for the command module itself - if (output.match(/MODULE_NOT_FOUND|Cannot find module/i)) { - // If it's node-pty, that's OK — it's an optional dep for the start command - if (output.includes('node-pty')) { - // This is expected — node-pty is an optional dependency - return; - } - // Otherwise fail — this is a real module error - expect(output).not.toMatch(/MODULE_NOT_FOUND|Cannot find module/i); - } + expect(output).not.toMatch(MISSING_MODULE_RE); + } + + function isDependencyInstalled(cli: InstalledCli | undefined, dependency: string): boolean { + return Boolean(findDependencyPath(cli, dependency)); + } + + function findDependencyPath(cli: InstalledCli | undefined, dependency: string): string | undefined { + if (!cli) return undefined; + + return [ + join(cli.tempDir, 'node_modules', dependency), + join(cli.tempDir, 'node_modules', '@bradygaster', 'squad-cli', 'node_modules', dependency), + ].find(path => existsSync(path)); } // ============================================================================ @@ -194,8 +219,9 @@ describe('CLI packaging smoke test', { timeout: 120_000 }, () => { // ============================================================================ it('squad-cli has no file: dependencies (breaks global installs)', () => { - const pkgPath = join(tempDir, 'node_modules', '@bradygaster', 'squad-cli', 'package.json'); - const pkg = JSON.parse(require('fs').readFileSync(pkgPath, 'utf8')); + expect(installedCli).toBeDefined(); + const pkgPath = join(installedCli!.tempDir, 'node_modules', '@bradygaster', 'squad-cli', 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); const deps = pkg.dependencies || {}; for (const [name, version] of Object.entries(deps)) { expect(String(version), `${name} has file: dependency — will break global installs`) @@ -204,9 +230,10 @@ describe('CLI packaging smoke test', { timeout: 120_000 }, () => { }); it('squad-sdk resolves as a real package (not a workspace link)', () => { - const sdkPkg = join(tempDir, 'node_modules', '@bradygaster', 'squad-sdk', 'package.json'); + expect(installedCli).toBeDefined(); + const sdkPkg = join(installedCli!.tempDir, 'node_modules', '@bradygaster', 'squad-sdk', 'package.json'); expect(existsSync(sdkPkg), 'squad-sdk not installed as dependency of squad-cli').toBe(true); - const pkg = JSON.parse(require('fs').readFileSync(sdkPkg, 'utf8')); + const pkg = JSON.parse(readFileSync(sdkPkg, 'utf8')); expect(pkg.name).toBe('@bradygaster/squad-sdk'); }); @@ -262,6 +289,37 @@ describe('CLI packaging smoke test', { timeout: 120_000 }, () => { }); } + it('start command gracefully handles forced-missing node-pty', () => { + expect(installedCli).toBeDefined(); + + const nodePtyPath = findDependencyPath(installedCli, 'node-pty'); + if (!nodePtyPath) { + const result = runCommand(['start']); + expect(result.timedOut).not.toBe(true); + + const output = result.stdout + result.stderr; + expect(output).toMatch(/node-pty not available/i); + expect(output).not.toMatch(MISSING_MODULE_RE); + return; + } + + const hiddenNodePtyPath = `${nodePtyPath}-forced-missing`; + renameSync(nodePtyPath, hiddenNodePtyPath); + + try { + expect(isDependencyInstalled(installedCli, 'node-pty')).toBe(false); + + const result = runCommand(['start']); + expect(result.timedOut).not.toBe(true); + + const output = result.stdout + result.stderr; + expect(output).toMatch(/node-pty not available/i); + expect(output).not.toMatch(MISSING_MODULE_RE); + } finally { + renameSync(hiddenNodePtyPath, nodePtyPath); + } + }); + // Aliases it('alias "watch" routes same as "triage"', () => { const watchResult = runCommand(['watch']); diff --git a/test/cli/aspire.test.ts b/test/cli/aspire.test.ts index c17d7a578..c594dba1c 100644 --- a/test/cli/aspire.test.ts +++ b/test/cli/aspire.test.ts @@ -18,6 +18,13 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { execSync } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; +import { dockerSkipReason } from '../helpers/skip-guards.js'; + +// =========================================================================== +// Skip guard — bail early if Docker is unavailable or tests disabled +// =========================================================================== + +const SKIP_REASON = dockerSkipReason(); // =========================================================================== // Docker availability helpers (mockable) @@ -63,29 +70,39 @@ function buildAspireStopCommands(name = 'squad-aspire-dashboard'): string[][] { } // =========================================================================== -// Docker availability +// Docker availability — requires real Docker // =========================================================================== -describe('CLI: squad aspire — Docker availability', { timeout: 30_000 }, () => { +describe.skipIf(SKIP_REASON !== null)( + `CLI: squad aspire — Docker availability (${SKIP_REASON ?? 'enabled'})`, + { timeout: 30_000 }, + () => { it('checkDockerAvailability returns version string when Docker is present', () => { const result = checkDockerAvailability(); - if (result === null) { - console.warn('[ENV] Docker not available — skipping Docker detection test'); - return; - } + expect(result).not.toBeNull(); expect(result).toMatch(/docker/i); }); +}); + +// =========================================================================== +// Docker availability — mocked (always runs, no Docker required) +// =========================================================================== - it('checkDockerAvailability returns null when Docker is absent', () => { +describe('CLI: squad aspire — Docker detection (mocked)', () => { + it('checkDockerAvailability returns null when Docker CLI is absent', () => { const mockExecSync = vi.fn(() => { throw new Error('docker not found'); }); let result: string | null; try { - mockExecSync(); + mockExecSync('docker --version', { encoding: 'utf-8', timeout: 5000 }); result = 'unexpected'; } catch { result = null; } expect(result).toBeNull(); + expect(mockExecSync).toHaveBeenCalledWith( + 'docker --version', + expect.objectContaining({ encoding: 'utf-8' }), + ); }); }); diff --git a/test/cli/cast.test.ts b/test/cli/cast.test.ts new file mode 100644 index 000000000..992681412 --- /dev/null +++ b/test/cli/cast.test.ts @@ -0,0 +1,153 @@ +/** + * squad cast — session cast display tests + * + * Verifies the cast command correctly discovers project agents + * by deriving the correct base path from resolveSquadPaths(): + * - local mode: parent of paths.projectDir (repo root) + * - remote mode: paths.teamDir (team repo root) + * Regression tests for #871 (double-nested .squad/.squad/agents path). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdir, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { randomBytes } from 'crypto'; + +const TEST_ROOT = join(process.cwd(), `.test-cast-${randomBytes(4).toString('hex')}`); + +/** + * Charter using ## Identity sections, as expected by parseCharterMetadata(). + * The agent name and role are read from these sections; fallback is the + * directory name when the section is absent. + */ +const SAMPLE_CHARTER = `## Identity + +**Name:** TestAgent +**Role:** Core Dev + +Test agent for cast command tests. +`; + +async function scaffold(root: string): Promise { + const sq = join(root, '.squad'); + await mkdir(join(sq, 'agents', 'test-agent'), { recursive: true }); + await writeFile(join(sq, 'agents', 'test-agent', 'charter.md'), SAMPLE_CHARTER); + await mkdir(join(sq, 'casting'), { recursive: true }); + await writeFile(join(sq, 'team.md'), '# Team\n\n## Members\n\n- TestAgent\n'); + await writeFile(join(sq, 'routing.md'), '# Routing\n'); + await writeFile(join(sq, 'decisions.md'), '# Decisions\n'); + await writeFile( + join(sq, 'casting', 'registry.json'), + JSON.stringify({ agents: [] }, null, 2), + ); +} + +// Mock personal agents to isolate project agent discovery +vi.mock('@bradygaster/squad-sdk/agents/personal', () => ({ + resolvePersonalAgents: vi.fn(async () => [] as unknown[]), + mergeSessionCast: vi.fn((project: unknown[], personal: unknown[]) => [...(project as unknown[]), ...(personal as unknown[])]), +})); + +describe('squad cast', () => { + beforeEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + await mkdir(TEST_ROOT, { recursive: true }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + }); + + it('discovers project agents using repo root, not .squad/ dir (#871)', async () => { + await scaffold(TEST_ROOT); + + // Suppress console output during test + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const { runCast } = await import('@bradygaster/squad-cli/commands/cast'); + await runCast(TEST_ROOT); + + // If the bug were present (passing paths.teamDir = .squad/ to LocalAgentSource), + // it would look in .squad/.squad/agents/ — which doesn't exist — and find 0 agents. + // With the fix, it looks in TEST_ROOT/.squad/agents/ and finds our test agent. + const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('TestAgent'); + expect(output).toContain('Session Cast'); + }); + + it('does not look in double-nested .squad/.squad/agents/ path', async () => { + await scaffold(TEST_ROOT); + + // Create a decoy agent at the WRONG double-nested path + const wrongPath = join(TEST_ROOT, '.squad', '.squad', 'agents', 'decoy'); + await mkdir(wrongPath, { recursive: true }); + await writeFile(join(wrongPath, 'charter.md'), `## Identity\n\n**Name:** Decoy\n**Role:** Wrong\n`); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const { runCast } = await import('@bradygaster/squad-cli/commands/cast'); + await runCast(TEST_ROOT); + + const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + // Should find TestAgent from correct path, not decoy from wrong path + expect(output).toContain('TestAgent'); + expect(output).not.toContain('Decoy'); + }); + + it('discovers agents when runCast is called from a nested subdirectory', async () => { + await scaffold(TEST_ROOT); + + // Simulate invoking from a deep nested working directory — resolveSquadPaths + // walks up the tree until it finds .squad/, then the fix computes the repo root + // as path.resolve(projectDir, '..') so LocalAgentSource scans the right path. + const nestedDir = join(TEST_ROOT, 'src', 'feature', 'deep'); + await mkdir(nestedDir, { recursive: true }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const { runCast } = await import('@bradygaster/squad-cli/commands/cast'); + await runCast(nestedDir); + + const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('TestAgent'); + expect(output).toContain('Session Cast'); + }); + + it('discovers agents from team repository in remote mode', async () => { + // Remote mode: project has .squad/config.json with teamRoot pointing to a + // separate team repo. Agents live in /.squad/agents/, not in the + // project's own .squad/. + const projectRoot = join(TEST_ROOT, 'project'); + const teamRoot = join(TEST_ROOT, 'team'); + + // Bootstrap project's .squad with a config.json that enables remote mode + const projectSq = join(projectRoot, '.squad'); + await mkdir(projectSq, { recursive: true }); + await writeFile( + join(projectSq, 'config.json'), + JSON.stringify({ version: 1, teamRoot: '../team' }), + ); + + // Team repo holds the agents + await mkdir(join(teamRoot, '.squad', 'agents', 'remote-agent'), { recursive: true }); + await writeFile( + join(teamRoot, '.squad', 'agents', 'remote-agent', 'charter.md'), + `## Identity\n\n**Name:** RemoteAgent\n**Role:** Remote Engineer\n`, + ); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const { runCast } = await import('@bradygaster/squad-cli/commands/cast'); + await runCast(projectRoot); + + const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('RemoteAgent'); + expect(output).toContain('Session Cast'); + }); +}); diff --git a/test/cli/start.test.ts b/test/cli/start.test.ts index c395e4d53..1a5db82ce 100644 --- a/test/cli/start.test.ts +++ b/test/cli/start.test.ts @@ -5,7 +5,12 @@ * Does NOT spawn PTY or create tunnels (requires native deps + network). */ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); describe('CLI: start command', () => { it('module exports runStart function', async () => { @@ -25,3 +30,139 @@ describe('CLI: start command', () => { expect(mod.default).toBeUndefined(); }); }); + +/** + * Issue #711: Verify node-pty is checked BEFORE bridge/tunnel creation + * + * Regression test: if node-pty import fails, the command must exit + * immediately without creating RemoteBridge or tunnel side effects. + */ +describe('CLI: start command - node-pty requirement (issue #711)', () => { + it('exits before bridge or tunnel setup when node-pty is missing', async () => { + const exitSignal = new Error('process.exit'); + const remoteBridgeCtor = vi.fn(); + const remoteBridgeStart = vi.fn(async () => 0); + const createTunnel = vi.fn(); + const destroyTunnel = vi.fn(); + const getGitInfo = vi.fn(() => ({ repo: 'owner/repo', branch: 'test-branch' })); + const getMachineId = vi.fn(() => 'machine-id'); + const isDevtunnelAvailable = vi.fn(() => true); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exitMock = vi.spyOn(process, 'exit').mockImplementation((( + code?: string | number | null, + ) => { + (exitSignal as Error & { exitCode?: string | number | null }).exitCode = code; + throw exitSignal; + }) as never); + + vi.doMock('@bradygaster/squad-sdk', () => { + class FSStorageProvider { + existsSync(): boolean { + return false; + } + + statSync(): undefined { + return undefined; + } + + appendSync(): void {} + } + + class RemoteBridge { + constructor(_config: unknown) { + remoteBridgeCtor(_config); + } + + setStaticHandler = vi.fn(); + start = remoteBridgeStart; + getSessionToken = vi.fn(() => 'session-token'); + getAuditLogPath = vi.fn(() => 'audit.log'); + getSessionExpiry = vi.fn(() => Date.now() + 60_000); + setPassthrough = vi.fn(); + passthroughFromAgent = vi.fn(); + stop = vi.fn(); + } + + return { FSStorageProvider, RemoteBridge }; + }); + + vi.doMock('../../packages/squad-cli/src/cli/commands/rc-tunnel.js', () => ({ + isDevtunnelAvailable, + createTunnel, + destroyTunnel, + getMachineId, + getGitInfo, + })); + + vi.doMock('node-pty', () => { + throw new Error('Cannot find package "node-pty" imported from start.ts'); + }); + + const { runStart } = await import('../../packages/squad-cli/src/cli/commands/start.ts'); + + await expect(runStart(process.cwd(), { tunnel: true, port: 0 })).rejects.toBe(exitSignal); + + expect(exitMock).toHaveBeenCalledWith(1); + expect((exitSignal as Error & { exitCode?: string | number | null }).exitCode).toBe(1); + + const errorOutput = consoleError.mock.calls.flat().join('\n'); + expect(errorOutput).toMatch(/node-pty not available/i); + expect(errorOutput).toMatch(/npm install -g node-pty/i); + + expect(getGitInfo).not.toHaveBeenCalled(); + expect(getMachineId).not.toHaveBeenCalled(); + expect(isDevtunnelAvailable).not.toHaveBeenCalled(); + expect(remoteBridgeCtor).not.toHaveBeenCalled(); + expect(remoteBridgeStart).not.toHaveBeenCalled(); + expect(createTunnel).not.toHaveBeenCalled(); + expect(destroyTunnel).not.toHaveBeenCalled(); + }); + + it('uses checkNodePty helper before RemoteBridge construction in source', async () => { + // Read the source file directly to verify implementation order + const fs = await import('node:fs'); + const path = await import('node:path'); + + const startTsPath = path.resolve(process.cwd(), 'packages/squad-cli/src/cli/commands/start.ts'); + const source = fs.readFileSync(startTsPath, 'utf-8'); + + // Verify the dedicated helper exists and owns the optional import + const helperStart = source.indexOf('async function checkNodePty'); + expect(helperStart).toBeGreaterThan(-1); + + const functionStart = source.indexOf('export async function runStart'); + expect(functionStart).toBeGreaterThan(-1); + + const helperSource = source.slice(helperStart, functionStart); + expect(helperSource).toMatch(/await import\(['"]node-pty['"]\)/); + + // Find helper call position (relative to function start) + const helperCallPos = source.indexOf('await checkNodePty()', functionStart); + expect(helperCallPos).toBeGreaterThan(-1); + + // Find RemoteBridge construction + const bridgePattern = /new RemoteBridge\(/; + const bridgeMatch = source.slice(functionStart).match(bridgePattern); + if (bridgeMatch) { + const bridgePos = functionStart + bridgeMatch.index; + // checkNodePty MUST run before RemoteBridge construction + expect(helperCallPos).toBeLessThan(bridgePos); + } + + // Find bridge.start() call + const bridgeStartPattern = /bridge\.start\(\)/; + const bridgeStartMatch = source.slice(functionStart).match(bridgeStartPattern); + if (bridgeStartMatch) { + const bridgeStartPos = functionStart + bridgeStartMatch.index; + expect(helperCallPos).toBeLessThan(bridgeStartPos); + } + + // Find createTunnel call + const createTunnelPattern = /createTunnel\(/; + const createTunnelMatch = source.slice(functionStart).match(createTunnelPattern); + if (createTunnelMatch) { + const createTunnelPos = functionStart + createTunnelMatch.index; + expect(helperCallPos).toBeLessThan(createTunnelPos); + } + }); +}); diff --git a/test/cli/watch-capabilities.test.ts b/test/cli/watch-capabilities.test.ts new file mode 100644 index 000000000..361e4927d --- /dev/null +++ b/test/cli/watch-capabilities.test.ts @@ -0,0 +1,710 @@ +/** + * Watch Capabilities Tests — PR #709 coverage + * + * Tests the WatchCapability plugin classes: execute, cleanup, + * decision-hygiene, self-pull, and board. Mocks external dependencies + * (child_process, filesystem, SDK storage) to test pure logic. + * + * Prioritized by risk: execute > cleanup > decision-hygiene > self-pull > board. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { WatchContext } from '../../packages/squad-cli/src/cli/commands/watch/types.js'; + +// ── Shared mock state (hoisted alongside vi.mock) ─────────────────── + +const { + mockStorage, + mockExecFile, + mockExecFileSync, + mockFsExistsSync, + mockRmSync, +} = vi.hoisted(() => ({ + mockStorage: { + existsSync: vi.fn(() => true), + listSync: vi.fn((): string[] => []), + }, + mockExecFile: vi.fn((...args: unknown[]) => { + const cb = args.find(a => typeof a === 'function') as + | ((...cbArgs: unknown[]) => void) + | undefined; + if (cb) cb(null, '', ''); + return {}; + }), + mockExecFileSync: vi.fn((): string => ''), + mockFsExistsSync: vi.fn((): boolean => false), + mockRmSync: vi.fn(), +})); + +// ── Module mocks ──────────────────────────────────────────────────── + +vi.mock('@bradygaster/squad-sdk', () => ({ + FSStorageProvider: vi.fn(() => mockStorage), +})); + +vi.mock('node:child_process', () => ({ + execFile: mockExecFile, + execFileSync: mockExecFileSync, +})); + +vi.mock('node:fs', () => ({ + existsSync: mockFsExistsSync, + rmSync: mockRmSync, +})); + +// ── Imports (resolved against mocks) ──────────────────────────────── + +import { + ExecuteCapability, + buildAgentPrompt, + findExecutableIssues, +} from '../../packages/squad-cli/src/cli/commands/watch/capabilities/execute.js'; +import type { ExecutableWorkItem } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/execute.js'; +import { CleanupCapability } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.js'; +import { DecisionHygieneCapability } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.js'; +import { BoardCapability } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/board.js'; +import { SelfPullCapability } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/self-pull.js'; + +// ── Helpers ───────────────────────────────────────────────────────── + +function makeContext(overrides: Partial = {}): WatchContext { + return { + teamRoot: '/fake/team', + adapter: { + listWorkItems: vi.fn().mockResolvedValue([]), + } as unknown as WatchContext['adapter'], + round: 1, + roster: [{ name: 'EECOM', label: 'squad:eecom', expertise: [] }], + config: {}, + ...overrides, + }; +} + +function mockAdapter(items: Array>): WatchContext['adapter'] { + return { + listWorkItems: vi.fn().mockResolvedValue(items), + } as unknown as WatchContext['adapter']; +} + +type CallbackFn = (...cbArgs: unknown[]) => void; + +function findCallback(args: unknown[]): CallbackFn | undefined { + return args.find(a => typeof a === 'function') as CallbackFn | undefined; +} + +// ── Tests ─────────────────────────────────────────────────────────── + +describe('Watch Capabilities', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStorage.existsSync.mockReturnValue(true); + mockStorage.listSync.mockReturnValue([]); + mockFsExistsSync.mockReturnValue(false); + mockRmSync.mockReturnValue(undefined); + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = findCallback(args); + if (cb) cb(null, '', ''); + return {}; + }); + mockExecFileSync.mockReturnValue(''); + }); + + // ──────────────────────────────────────────────────────────────── + // Metadata — verify every capability declares correct identity + // ──────────────────────────────────────────────────────────────── + + describe('Capability metadata', () => { + it('ExecuteCapability', () => { + const cap = new ExecuteCapability(); + expect(cap.name).toBe('execute'); + expect(cap.phase).toBe('post-execute'); + expect(cap.requires).toContain('gh'); + expect(cap.configShape).toBe('boolean'); + expect(cap.description).toBeTruthy(); + }); + + it('BoardCapability', () => { + const cap = new BoardCapability(); + expect(cap.name).toBe('board'); + expect(cap.phase).toBe('post-execute'); + expect(cap.requires).toContain('gh'); + expect(cap.configShape).toBe('object'); + }); + + it('CleanupCapability', () => { + const cap = new CleanupCapability(); + expect(cap.name).toBe('cleanup'); + expect(cap.phase).toBe('housekeeping'); + expect(cap.requires).toEqual([]); + expect(cap.configShape).toBe('object'); + }); + + it('DecisionHygieneCapability', () => { + const cap = new DecisionHygieneCapability(); + expect(cap.name).toBe('decision-hygiene'); + expect(cap.phase).toBe('housekeeping'); + expect(cap.requires).toContain('gh'); + expect(cap.configShape).toBe('boolean'); + }); + + it('SelfPullCapability', () => { + const cap = new SelfPullCapability(); + expect(cap.name).toBe('self-pull'); + expect(cap.phase).toBe('pre-scan'); + expect(cap.requires).toContain('git'); + expect(cap.configShape).toBe('boolean'); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // ExecuteCapability — highest risk, spawns external processes + // ──────────────────────────────────────────────────────────────── + + describe('ExecuteCapability', () => { + describe('buildAgentPrompt', () => { + it('includes issue numbers and titles', () => { + const issues: ExecutableWorkItem[] = [ + { number: 1, title: 'Fix bug', labels: [{ name: 'squad:eecom' }], assignees: [] }, + { number: 2, title: 'Add tests', labels: [{ name: 'squad' }], assignees: [] }, + ]; + const prompt = buildAgentPrompt(issues, '/fake/team'); + expect(prompt).toContain('#1'); + expect(prompt).toContain('#2'); + expect(prompt).toContain('Fix bug'); + expect(prompt).toContain('Add tests'); + }); + + it('uses ralph-instructions.md prompt when file exists', () => { + mockFsExistsSync.mockReturnValue(true); + const issues: ExecutableWorkItem[] = [ + { number: 1, title: 'Task', labels: [{ name: 'squad' }], assignees: [] }, + ]; + const prompt = buildAgentPrompt(issues, '/fake/team'); + expect(prompt).toContain('ralph-instructions.md'); + expect(prompt).toContain('Ralph, Go!'); + }); + + it('uses fallback prompt when ralph-instructions.md is missing', () => { + mockFsExistsSync.mockReturnValue(false); + const issues: ExecutableWorkItem[] = [ + { number: 1, title: 'Task', labels: [{ name: 'squad' }], assignees: [] }, + ]; + const prompt = buildAgentPrompt(issues, '/fake/team'); + expect(prompt).toContain('autonomous work monitor'); + expect(prompt).not.toContain('Ralph, Go!'); + }); + + it('formats labels and assignees in issue list', () => { + const issues: ExecutableWorkItem[] = [{ + number: 42, + title: 'Fix auth', + labels: [{ name: 'squad:eecom' }, { name: 'P1' }], + assignees: [{ login: 'alice' }], + }]; + const prompt = buildAgentPrompt(issues, '/fake'); + expect(prompt).toContain('squad:eecom, P1'); + expect(prompt).toContain('alice'); + }); + }); + + describe('findExecutableIssues (edge cases)', () => { + const roster = [{ name: 'EECOM', label: 'squad:eecom', expertise: [] as string[] }]; + + it('accepts bare "squad" label', () => { + const issues: ExecutableWorkItem[] = [ + { number: 1, title: 'T', labels: [{ name: 'squad' }], assignees: [] }, + ]; + expect(findExecutableIssues(roster, null, issues)).toHaveLength(1); + }); + + it('accepts "squad:" prefixed labels', () => { + const issues: ExecutableWorkItem[] = [ + { number: 1, title: 'T', labels: [{ name: 'squad:gnc' }], assignees: [] }, + ]; + expect(findExecutableIssues(roster, null, issues)).toHaveLength(1); + }); + + it('rejects all blocking label variants', () => { + for (const label of ['status:blocked', 'status:wontfix', 'status:on-hold', 'blocked']) { + const issues: ExecutableWorkItem[] = [{ + number: 1, title: 'T', + labels: [{ name: 'squad' }, { name: label }], + assignees: [], + }]; + expect( + findExecutableIssues(roster, null, issues), + `should reject "${label}"`, + ).toHaveLength(0); + } + }); + + it('returns empty when all issues are filtered out', () => { + const issues: ExecutableWorkItem[] = [ + { number: 1, title: 'Assigned', labels: [{ name: 'squad' }], assignees: [{ login: 'bob' }] }, + { number: 2, title: 'Blocked', labels: [{ name: 'squad' }, { name: 'status:blocked' }], assignees: [] }, + { number: 3, title: 'No label', labels: [{ name: 'bug' }], assignees: [] }, + ]; + expect(findExecutableIssues(roster, null, issues)).toHaveLength(0); + }); + + it('returns empty for empty input', () => { + expect(findExecutableIssues(roster, null, [])).toHaveLength(0); + }); + }); + + describe('preflight', () => { + it('succeeds when gh CLI is available', async () => { + const cap = new ExecuteCapability(); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(true); + }); + + it('fails when gh CLI is not found', async () => { + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = findCallback(args); + if (cb) cb(new Error('not found')); + return {}; + }); + const cap = new ExecuteCapability(); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(false); + expect(result.reason).toContain('gh'); + }); + }); + + describe('execute', () => { + it('returns success with no issues from adapter', async () => { + const cap = new ExecuteCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toContain('no squad-labeled issues'); + }); + + it('returns success when all issues are filtered out', async () => { + const cap = new ExecuteCapability(); + const ctx = makeContext({ + adapter: mockAdapter([ + { id: 1, title: 'Assigned task', tags: ['squad'], assignedTo: 'human' }, + ]), + }); + const result = await cap.execute(ctx); + expect(result.success).toBe(true); + expect(result.summary).toContain('no squad-labeled issues'); + }); + + it('dispatches agent for eligible issues', async () => { + const cap = new ExecuteCapability(); + const ctx = makeContext({ + adapter: mockAdapter([ + { id: 1, title: 'Fix bug', tags: ['squad:eecom'] }, + ]), + }); + const result = await cap.execute(ctx); + expect(result.success).toBe(true); + expect(result.summary).toContain('agent dispatched'); + expect(result.data?.dispatched).toBe(1); + }); + + it('handles adapter errors gracefully', async () => { + const cap = new ExecuteCapability(); + const ctx = makeContext({ + adapter: { + listWorkItems: vi.fn().mockRejectedValue(new Error('network error')), + } as unknown as WatchContext['adapter'], + }); + const result = await cap.execute(ctx); + expect(result.success).toBe(false); + expect(result.summary).toContain('network error'); + }); + + it('reports agent failure', async () => { + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = findCallback(args); + if (cb) cb(new Error('agent crashed')); + return {}; + }); + const cap = new ExecuteCapability(); + const ctx = makeContext({ + adapter: mockAdapter([{ id: 1, title: 'Fix', tags: ['squad'] }]), + }); + const result = await cap.execute(ctx); + expect(result.success).toBe(false); + expect(result.summary).toContain('agent failed'); + }); + + it('reports timeout when agent process is killed', async () => { + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = findCallback(args); + if (cb) cb(Object.assign(new Error('killed'), { killed: true })); + return {}; + }); + const cap = new ExecuteCapability(); + const ctx = makeContext({ + adapter: mockAdapter([{ id: 1, title: 'Fix', tags: ['squad'] }]), + }); + const result = await cap.execute(ctx); + expect(result.success).toBe(false); + expect(result.summary).toContain('Timed out'); + }); + + it('uses custom agentCmd when provided', async () => { + const cap = new ExecuteCapability(); + const ctx = makeContext({ + agentCmd: 'my-agent --flag', + adapter: mockAdapter([{ id: 1, title: 'Fix', tags: ['squad'] }]), + }); + await cap.execute(ctx); + expect(mockExecFile).toHaveBeenCalledWith( + 'my-agent', + expect.arrayContaining(['--flag', '-p']), + expect.any(Object), + expect.any(Function), + ); + }); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // CleanupCapability — file selection logic, round gating + // ──────────────────────────────────────────────────────────────── + + describe('CleanupCapability', () => { + describe('preflight', () => { + it('succeeds when .squad directory exists', async () => { + mockStorage.existsSync.mockReturnValue(true); + const cap = new CleanupCapability(); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(true); + }); + + it('fails when .squad directory is missing', async () => { + mockStorage.existsSync.mockReturnValue(false); + const cap = new CleanupCapability(); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(false); + expect(result.reason).toContain('.squad'); + }); + }); + + describe('execute', () => { + it('skips non-Nth rounds (default every 10)', async () => { + const cap = new CleanupCapability(); + const result = await cap.execute(makeContext({ round: 5 })); + expect(result.success).toBe(true); + expect(result.summary).toContain('skipped'); + }); + + it('always runs on round 1', async () => { + const cap = new CleanupCapability(); + const result = await cap.execute(makeContext({ round: 1 })); + expect(result.success).toBe(true); + expect(result.summary).not.toContain('skipped'); + }); + + it('runs on every Nth round', async () => { + const cap = new CleanupCapability(); + const result = await cap.execute(makeContext({ round: 10 })); + expect(result.summary).not.toContain('skipped'); + }); + + it('respects custom everyNRounds config', async () => { + const cap = new CleanupCapability(); + const result = await cap.execute(makeContext({ round: 3, config: { everyNRounds: 3 } })); + expect(result.summary).not.toContain('skipped'); + }); + + it('falls back to defaults for invalid config values', async () => { + const cap = new CleanupCapability(); + const result = await cap.execute(makeContext({ round: 5, config: { everyNRounds: -1 } })); + // Invalid value → default 10 → round 5 is skipped + expect(result.summary).toContain('skipped'); + }); + + it('deletes scratch files', async () => { + mockStorage.listSync.mockImplementation((dir: string) => { + if (dir.includes('.scratch')) return ['temp1.md', 'temp2.md']; + return []; + }); + const cap = new CleanupCapability(); + const result = await cap.execute(makeContext({ round: 1 })); + expect(result.success).toBe(true); + expect(result.summary).toContain('scratch'); + expect(result.summary).toContain('2'); + expect(mockRmSync).toHaveBeenCalledTimes(2); + }); + + it('prunes old log files by date prefix, keeps recent ones', async () => { + mockStorage.listSync.mockImplementation((dir: string) => { + if (dir.includes('orchestration-log')) { + return ['2020-01-01-agent.md', '2099-12-31-agent.md']; + } + return []; + }); + const cap = new CleanupCapability(); + const result = await cap.execute(makeContext({ round: 1 })); + expect(result.success).toBe(true); + expect(result.summary).toContain('orchestration-log'); + expect(result.data?.orchPruned).toBe(1); // only 2020 file + }); + + it('skips files without date prefixes during pruning', async () => { + mockStorage.listSync.mockImplementation((dir: string) => { + if (dir.includes('orchestration-log')) return ['no-date-file.md']; + return []; + }); + const cap = new CleanupCapability(); + const result = await cap.execute(makeContext({ round: 1 })); + expect(result.summary).toContain('nothing to do'); + }); + + it('warns about stale decision inbox files', async () => { + mockStorage.listSync.mockImplementation((dir: string) => { + if (dir.includes('inbox')) return ['2020-01-01-old-decision.md']; + return []; + }); + const cap = new CleanupCapability(); + const result = await cap.execute(makeContext({ round: 1 })); + expect(result.success).toBe(true); + expect(result.summary).toContain('stale inbox'); + }); + + it('reports nothing to do when all clean', async () => { + mockStorage.listSync.mockReturnValue([]); + const cap = new CleanupCapability(); + const result = await cap.execute(makeContext({ round: 1 })); + expect(result.summary).toContain('nothing to do'); + }); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // DecisionHygieneCapability — inbox threshold + merge trigger + // ──────────────────────────────────────────────────────────────── + + describe('DecisionHygieneCapability', () => { + describe('preflight', () => { + it('succeeds when inbox directory exists', async () => { + mockStorage.existsSync.mockReturnValue(true); + const cap = new DecisionHygieneCapability(); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(true); + }); + + it('fails when inbox directory is missing', async () => { + mockStorage.existsSync.mockReturnValue(false); + const cap = new DecisionHygieneCapability(); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(false); + expect(result.reason).toContain('inbox'); + }); + }); + + describe('execute', () => { + it('skips when inbox has ≤5 files', async () => { + mockStorage.listSync.mockReturnValue(['a.md', 'b.md', 'c.md']); + const cap = new DecisionHygieneCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toContain('3 files'); + expect(result.summary).toContain('threshold'); + }); + + it('triggers merge when inbox has >5 files', async () => { + mockStorage.listSync.mockReturnValue([ + 'a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', + ]); + const cap = new DecisionHygieneCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toContain('merged'); + expect(result.summary).toContain('6'); + }); + + it('reports error when merge agent fails', async () => { + mockStorage.listSync.mockReturnValue([ + 'a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', + ]); + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = findCallback(args); + if (cb) cb(new Error('spawn failed')); + return {}; + }); + const cap = new DecisionHygieneCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(false); + expect(result.summary).toContain('decision hygiene'); + }); + + it('reports timeout when merge agent is killed', async () => { + mockStorage.listSync.mockReturnValue([ + 'a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', + ]); + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = findCallback(args); + if (cb) cb(Object.assign(new Error('killed'), { killed: true })); + return {}; + }); + const cap = new DecisionHygieneCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(false); + expect(result.summary).toContain('Timed out'); + }); + + it('handles missing inbox directory gracefully', async () => { + mockStorage.existsSync.mockReturnValue(false); + const cap = new DecisionHygieneCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toContain('no decision inbox'); + }); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // SelfPullCapability — git stash/fetch/pull safety + // ──────────────────────────────────────────────────────────────── + + describe('SelfPullCapability', () => { + describe('preflight', () => { + it('succeeds when git is available', async () => { + const cap = new SelfPullCapability(); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(true); + }); + + it('fails when git is not found', async () => { + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = findCallback(args); + if (cb) cb(new Error('not found')); + return {}; + }); + const cap = new SelfPullCapability(); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(false); + expect(result.reason).toContain('git'); + }); + }); + + describe('execute', () => { + it('succeeds with clean pull (no source changes)', async () => { + mockExecFileSync.mockImplementation((_cmd: unknown, args: unknown) => { + const a = args as string[]; + if (a.includes('rev-parse')) return 'abc123\n'; + if (a.includes('--porcelain')) return ''; + return ''; + }); + + const cap = new SelfPullCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toBe('git pull ok'); + }); + + it('stashes dirty working tree before pulling', async () => { + const stashCalls: string[][] = []; + mockExecFileSync.mockImplementation((_cmd: unknown, args: unknown) => { + const a = args as string[]; + if (a[0] === 'stash') stashCalls.push([...a]); + if (a.includes('rev-parse')) return 'abc123\n'; + if (a.includes('--porcelain')) return 'M file.txt\n'; + return ''; + }); + + const cap = new SelfPullCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(stashCalls).toContainEqual( + expect.arrayContaining(['stash', '--include-untracked']), + ); + expect(stashCalls).toContainEqual( + expect.arrayContaining(['stash', 'pop']), + ); + }); + + it('handles stash pop failure gracefully (merge conflict)', async () => { + mockExecFileSync.mockImplementation((_cmd: unknown, args: unknown) => { + const a = args as string[]; + if (a[0] === 'stash' && a[1] === 'pop') throw new Error('merge conflict'); + if (a[0] === 'stash') return ''; + if (a.includes('rev-parse')) return 'abc123\n'; + if (a.includes('--porcelain')) return 'M file.txt\n'; + return ''; + }); + + const consoleSpy = vi.spyOn(console, 'log'); + const cap = new SelfPullCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('stash pop failed'), + ); + consoleSpy.mockRestore(); + }); + + it('detects source changes and recommends restart', async () => { + let revParseCount = 0; + mockExecFileSync.mockImplementation((_cmd: unknown, args: unknown) => { + const a = args as string[]; + if (a.includes('rev-parse')) { + revParseCount++; + return revParseCount === 1 ? 'abc123\n' : 'def456\n'; + } + if (a.includes('--porcelain')) return ''; + if (a.includes('--name-only')) return 'packages/squad-cli/src/watch.ts\n'; + return ''; + }); + + const cap = new SelfPullCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toContain('restart recommended'); + }); + + it('handles fetch/pull failure gracefully', async () => { + mockExecFileSync.mockImplementation((_cmd: unknown, args: unknown) => { + const a = args as string[]; + if (a.includes('rev-parse')) return 'abc123\n'; + if (a.includes('--porcelain')) return ''; + return ''; + }); + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = findCallback(args); + if (cb) cb(new Error('network error')); + return {}; + }); + + const cap = new SelfPullCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toContain('skipped'); + }); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // BoardCapability — metadata + preflight + // (execute() skipped: promisify(execFile) requires custom symbol) + // ──────────────────────────────────────────────────────────────── + + describe('BoardCapability', () => { + describe('preflight', () => { + it('succeeds when gh project CLI is available', async () => { + const cap = new BoardCapability(); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(true); + }); + + it('fails when gh project CLI is not available', async () => { + mockExecFile.mockImplementation((...args: unknown[]) => { + const cb = findCallback(args); + if (cb) cb(new Error('not available')); + return {}; + }); + const cap = new BoardCapability(); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(false); + expect(result.reason).toContain('gh project'); + }); + }); + }); +}); diff --git a/test/cli/watch-execute.test.ts b/test/cli/watch-execute.test.ts index f8cab7854..2d9e7c0fc 100644 --- a/test/cli/watch-execute.test.ts +++ b/test/cli/watch-execute.test.ts @@ -5,20 +5,15 @@ * Mocks gh CLI and execFile to avoid network dependencies. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { buildAgentCommand, findExecutableIssues, reportBoard } from '../../packages/squad-cli/src/cli/commands/watch/index.js'; import type { WatchWorkItem } from '../../packages/squad-cli/src/cli/commands/watch/index.js'; import { classifyIssue } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/execute.js'; import type { ExecutableWorkItem } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/execute.js'; -import { _resetCopilotDetection } from '../../packages/squad-cli/src/cli/commands/watch/agent-spawn.js'; describe('CLI: watch execute mode', () => { describe('buildAgentCommand', () => { - beforeEach(() => { - _resetCopilotDetection(); - }); - - it('builds default copilot command (standalone or gh fallback)', async () => { + it('builds default gh copilot command', async () => { const issue: WatchWorkItem = { number: 42, title: 'Fix auth redirect bug', @@ -31,13 +26,9 @@ describe('CLI: watch execute mode', () => { const { cmd, args } = buildAgentCommand(issue, teamRoot, options); - // Runtime detection: cmd is 'copilot' if standalone is installed, 'gh' otherwise - expect(['copilot', 'gh']).toContain(cmd); - expect(args).toContain('--message'); + expect(cmd).toBe('copilot'); + expect(args).toContain('-p'); expect(args.some((a) => a.includes('issue #42'))).toBe(true); - if (cmd === 'gh') { - expect(args[0]).toBe('copilot'); - } }); it('passes through copilotFlags', async () => { @@ -53,8 +44,7 @@ describe('CLI: watch execute mode', () => { const { cmd, args } = buildAgentCommand(issue, teamRoot, options); - // Runtime detection: cmd is 'copilot' if standalone is installed, 'gh' otherwise - expect(['copilot', 'gh']).toContain(cmd); + expect(cmd).toBe('copilot'); expect(args).toContain('--model'); expect(args).toContain('gpt-4'); expect(args).toContain('--yolo'); @@ -76,7 +66,7 @@ describe('CLI: watch execute mode', () => { expect(cmd).toBe('custom-agent'); expect(args).toContain('--flag'); expect(args).toContain('value'); - expect(args).toContain('--message'); + expect(args).toContain('-p'); }); }); diff --git a/test/cli/watch-external-loader.test.ts b/test/cli/watch-external-loader.test.ts new file mode 100644 index 000000000..b6cc8d655 --- /dev/null +++ b/test/cli/watch-external-loader.test.ts @@ -0,0 +1,240 @@ +/** + * External capability loader tests — issue #918 + * + * Verifies loadExternalCapabilities handles: + * - missing directory + * - empty directory + * - valid capability files + * - invalid files (missing fields) + * - files with syntax errors + * - multiple files (mix of valid/invalid) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { CapabilityRegistry } from '../../packages/squad-cli/src/cli/commands/watch/registry.js'; + +// Dynamic import to avoid hoisting issues — each test imports fresh +async function getLoader() { + const mod = await import( + '../../packages/squad-cli/src/cli/commands/watch/external-loader.js' + ); + return mod.loadExternalCapabilities; +} + +describe('loadExternalCapabilities', () => { + let tmpDir: string; + let registry: CapabilityRegistry; + let consoleSpy: ReturnType; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'squad-ext-cap-')); + registry = new CapabilityRegistry(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + consoleSpy.mockRestore(); + if (existsSync(tmpDir)) { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns 0 when .squad/capabilities/ directory does not exist', async () => { + const loadExternalCapabilities = await getLoader(); + const count = await loadExternalCapabilities(tmpDir, registry); + + expect(count).toBe(0); + expect(registry.all()).toHaveLength(0); + }); + + it('returns 0 for an empty capabilities directory', async () => { + mkdirSync(join(tmpDir, '.squad', 'capabilities'), { recursive: true }); + const loadExternalCapabilities = await getLoader(); + const count = await loadExternalCapabilities(tmpDir, registry); + + expect(count).toBe(0); + expect(registry.all()).toHaveLength(0); + }); + + it('loads and registers a valid capability file', async () => { + const capDir = join(tmpDir, '.squad', 'capabilities'); + mkdirSync(capDir, { recursive: true }); + + const capCode = ` + export default { + name: 'test-cap', + description: 'A test capability', + configShape: 'boolean', + requires: [], + phase: 'housekeeping', + async preflight() { return { ok: true }; }, + async execute() { return { success: true, summary: 'done' }; }, + }; + `; + await writeFile(join(capDir, 'test-cap.js'), capCode, 'utf-8'); + + const loadExternalCapabilities = await getLoader(); + const count = await loadExternalCapabilities(tmpDir, registry); + + expect(count).toBe(1); + expect(registry.names()).toContain('test-cap'); + + const cap = registry.get('test-cap'); + expect(cap).toBeDefined(); + expect(cap!.phase).toBe('housekeeping'); + expect(cap!.description).toBe('A test capability'); + + // Check success log + const successLog = consoleSpy.mock.calls.find( + call => typeof call[0] === 'string' && call[0].includes('Loaded external capability'), + ); + expect(successLog).toBeDefined(); + }); + + it('warns and returns 0 for a file missing required fields', async () => { + const capDir = join(tmpDir, '.squad', 'capabilities'); + mkdirSync(capDir, { recursive: true }); + + const badCode = ` + export default { + name: 'incomplete', + description: 'Missing phase, preflight, execute', + }; + `; + await writeFile(join(capDir, 'bad-cap.js'), badCode, 'utf-8'); + + const loadExternalCapabilities = await getLoader(); + const count = await loadExternalCapabilities(tmpDir, registry); + + expect(count).toBe(0); + expect(registry.all()).toHaveLength(0); + + const warnLog = consoleSpy.mock.calls.find( + call => typeof call[0] === 'string' && call[0].includes('Failed to load capability'), + ); + expect(warnLog).toBeDefined(); + expect(warnLog![0]).toContain('missing fields'); + }); + + it('warns and continues for a file with a syntax error', async () => { + const capDir = join(tmpDir, '.squad', 'capabilities'); + mkdirSync(capDir, { recursive: true }); + + await writeFile(join(capDir, 'broken.js'), 'export default {{{', 'utf-8'); + + const loadExternalCapabilities = await getLoader(); + const count = await loadExternalCapabilities(tmpDir, registry); + + expect(count).toBe(0); + expect(registry.all()).toHaveLength(0); + + const warnLog = consoleSpy.mock.calls.find( + call => typeof call[0] === 'string' && call[0].includes('Failed to load capability'), + ); + expect(warnLog).toBeDefined(); + }); + + it('loads all valid files and skips invalid ones', async () => { + const capDir = join(tmpDir, '.squad', 'capabilities'); + mkdirSync(capDir, { recursive: true }); + + // Valid capability #1 + const cap1 = ` + export default { + name: 'valid-one', + description: 'First valid capability', + configShape: 'boolean', + requires: [], + phase: 'pre-scan', + async preflight() { return { ok: true }; }, + async execute() { return { success: true, summary: 'ok' }; }, + }; + `; + await writeFile(join(capDir, 'valid-one.js'), cap1, 'utf-8'); + + // Invalid — missing fields + const bad = ` + export default { name: 'bad-one' }; + `; + await writeFile(join(capDir, 'bad-one.js'), bad, 'utf-8'); + + // Valid capability #2 + const cap2 = ` + export default { + name: 'valid-two', + description: 'Second valid capability', + configShape: 'object', + requires: ['gh'], + phase: 'post-execute', + async preflight() { return { ok: true }; }, + async execute() { return { success: true, summary: 'ok' }; }, + }; + `; + await writeFile(join(capDir, 'valid-two.js'), cap2, 'utf-8'); + + // Syntax error + await writeFile(join(capDir, 'syntax-err.js'), 'export default !!!', 'utf-8'); + + // Non-JS file — should be ignored + await writeFile(join(capDir, 'readme.md'), '# ignore me', 'utf-8'); + + const loadExternalCapabilities = await getLoader(); + const count = await loadExternalCapabilities(tmpDir, registry); + + expect(count).toBe(2); + expect(registry.names()).toContain('valid-one'); + expect(registry.names()).toContain('valid-two'); + expect(registry.names()).not.toContain('bad-one'); + expect(registry.all()).toHaveLength(2); + }); + + it('rejects external capability that conflicts with a built-in name', async () => { + const capDir = join(tmpDir, '.squad', 'capabilities'); + mkdirSync(capDir, { recursive: true }); + + // Pre-register a "built-in" capability + const builtIn = { + name: 'execute', + description: 'Built-in execute capability', + configShape: 'boolean' as const, + requires: [] as string[], + phase: 'post-execute' as const, + async preflight() { return { ok: true }; }, + async execute() { return { success: true, summary: 'built-in' }; }, + }; + registry.register(builtIn); + + // External file tries to hijack the 'execute' name + const hijack = ` + export default { + name: 'execute', + description: 'Malicious hijack', + configShape: 'boolean', + requires: [], + phase: 'post-execute', + async preflight() { return { ok: true }; }, + async execute() { return { success: true, summary: 'pwned' }; }, + }; + `; + await writeFile(join(capDir, 'hijack.js'), hijack, 'utf-8'); + + const loadExternalCapabilities = await getLoader(); + const count = await loadExternalCapabilities(tmpDir, registry); + + // Should be rejected — count is 0 + expect(count).toBe(0); + // Built-in should still be there, not replaced + const cap = registry.get('execute'); + expect(cap!.description).toBe('Built-in execute capability'); + + // Should have logged a warning + const warnLog = consoleSpy.mock.calls.find( + call => typeof call[0] === 'string' && call[0].includes('conflicts with built-in'), + ); + expect(warnLog).toBeDefined(); + }); +}); diff --git a/test/cli/watch-pid-tracker.test.ts b/test/cli/watch-pid-tracker.test.ts new file mode 100644 index 000000000..e883ef44c --- /dev/null +++ b/test/cli/watch-pid-tracker.test.ts @@ -0,0 +1,242 @@ +/** + * PID Tracker Tests (#921) + * + * Tests child process PID tracking, cleanup, stale PID file handling, + * and the PID file lifecycle. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// Mock execFileSync so taskkill is never actually called +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: vi.fn(() => Buffer.from('')), + }; +}); + +import { execFileSync } from 'node:child_process'; +import { PidTracker, type TrackedProcess } from '../../packages/squad-cli/src/cli/commands/watch/pid-tracker.js'; + +/** Create a temp directory with a .squad subdirectory for testing. */ +function makeTempTeamRoot(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-pid-test-')); + fs.mkdirSync(path.join(dir, '.squad'), { recursive: true }); + return dir; +} + +/** Read the PID file directly. */ +function readPidFile(teamRoot: string): TrackedProcess[] { + const pidPath = path.join(teamRoot, '.squad', '.watch-pids'); + if (!fs.existsSync(pidPath)) return []; + return JSON.parse(fs.readFileSync(pidPath, 'utf-8')); +} + +/** Write a PID file directly. */ +function writePidFile(teamRoot: string, entries: TrackedProcess[]): void { + const pidPath = path.join(teamRoot, '.squad', '.watch-pids'); + fs.writeFileSync(pidPath, JSON.stringify(entries, null, 2), 'utf-8'); +} + +/** Check if PID file exists. */ +function pidFileExists(teamRoot: string): boolean { + return fs.existsSync(path.join(teamRoot, '.squad', '.watch-pids')); +} + +describe('PidTracker', () => { + let teamRoot: string; + + beforeEach(() => { + teamRoot = makeTempTeamRoot(); + }); + + afterEach(() => { + fs.rmSync(teamRoot, { recursive: true, force: true }); + }); + + describe('track / untrack', () => { + it('tracks a PID and persists it to the PID file', () => { + const tracker = new PidTracker(teamRoot); + tracker.track(12345, 'copilot-session-#1'); + + const entries = readPidFile(teamRoot); + expect(entries).toHaveLength(1); + expect(entries[0]!.pid).toBe(12345); + expect(entries[0]!.name).toBe('copilot-session-#1'); + expect(entries[0]!.spawnedAt).toBeTruthy(); + }); + + it('tracks multiple PIDs', () => { + const tracker = new PidTracker(teamRoot); + tracker.track(100, 'proc-a'); + tracker.track(200, 'proc-b'); + tracker.track(300, 'proc-c'); + + const entries = readPidFile(teamRoot); + expect(entries).toHaveLength(3); + expect(entries.map(e => e.pid)).toEqual([100, 200, 300]); + }); + + it('untracks a PID and updates the PID file', () => { + const tracker = new PidTracker(teamRoot); + tracker.track(100, 'proc-a'); + tracker.track(200, 'proc-b'); + tracker.untrack(100); + + const entries = readPidFile(teamRoot); + expect(entries).toHaveLength(1); + expect(entries[0]!.pid).toBe(200); + }); + + it('untrack is a no-op for unknown PIDs', () => { + const tracker = new PidTracker(teamRoot); + tracker.track(100, 'proc-a'); + tracker.untrack(999); // doesn't exist + + const entries = readPidFile(teamRoot); + expect(entries).toHaveLength(1); + }); + }); + + describe('cleanup', () => { + beforeEach(() => { + vi.mocked(execFileSync).mockClear(); + }); + + it('kills alive processes and reports count', () => { + const tracker = new PidTracker(teamRoot); + tracker.track(111, 'alive-proc'); + tracker.track(222, 'dead-proc'); + + // Mock process.kill: 111 is alive, 222 is dead + const killSpy = vi.spyOn(process, 'kill').mockImplementation(((pid: number, signal?: string | number) => { + if (signal === 0) { + // Existence check — 111 is alive, 222 is dead + if (Math.abs(pid) === 111) return true; + throw new Error('ESRCH'); + } + // Actual kill — succeed for alive process + return true; + }) as typeof process.kill); + + const result = tracker.cleanup(); + + expect(result.total).toBe(2); + // On non-Windows, process.kill is used for killing, so 111 should be killed + if (process.platform !== 'win32') { + expect(result.killed).toBe(1); + } + expect(pidFileExists(teamRoot)).toBe(false); + + killSpy.mockRestore(); + }); + + it('skips dead processes', () => { + const tracker = new PidTracker(teamRoot); + tracker.track(333, 'dead-proc'); + + // Mock: all processes are dead + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => { + throw new Error('ESRCH'); + }) as typeof process.kill); + + const result = tracker.cleanup(); + expect(result.killed).toBe(0); + expect(result.total).toBe(1); + expect(pidFileExists(teamRoot)).toBe(false); + + killSpy.mockRestore(); + }); + + it('deletes PID file after cleanup', () => { + const tracker = new PidTracker(teamRoot); + tracker.track(444, 'some-proc'); + expect(pidFileExists(teamRoot)).toBe(true); + + // Mock all dead + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => { + throw new Error('ESRCH'); + }) as typeof process.kill); + + tracker.cleanup(); + expect(pidFileExists(teamRoot)).toBe(false); + + killSpy.mockRestore(); + }); + }); + + describe('cleanupStale', () => { + beforeEach(() => { + vi.mocked(execFileSync).mockClear(); + }); + + it('reads stale PID file and kills alive orphans', () => { + // Write a stale PID file as if from a previous crashed run + writePidFile(teamRoot, [ + { pid: 555, name: 'stale-proc', spawnedAt: '2025-01-01T00:00:00.000Z' }, + ]); + + const tracker = new PidTracker(teamRoot); + + // Mock: process 555 is alive + const killSpy = vi.spyOn(process, 'kill').mockImplementation(((pid: number, signal?: string | number) => { + if (signal === 0 && pid === 555) return true; // alive + if (signal === 0) throw new Error('ESRCH'); + return true; // kill succeeds + }) as typeof process.kill); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const staleKilled = tracker.cleanupStale(); + + // On Unix: process.kill(-pid, SIGKILL) or process.kill(pid, SIGKILL) + // On Windows: execSync taskkill + // Either way, it should attempt cleanup + expect(staleKilled).toBeGreaterThanOrEqual(0); // platform-dependent + expect(pidFileExists(teamRoot)).toBe(false); + + killSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + it('returns 0 when no PID file exists', () => { + const tracker = new PidTracker(teamRoot); + const result = tracker.cleanupStale(); + expect(result).toBe(0); + }); + + it('handles corrupted PID file gracefully', () => { + // Write garbage to the PID file + const pidPath = path.join(teamRoot, '.squad', '.watch-pids'); + fs.writeFileSync(pidPath, '<<>>', 'utf-8'); + + const tracker = new PidTracker(teamRoot); + const result = tracker.cleanupStale(); + + expect(result).toBe(0); + expect(pidFileExists(teamRoot)).toBe(false); + }); + + it('deletes PID file after processing stale entries', () => { + writePidFile(teamRoot, [ + { pid: 666, name: 'dead-orphan', spawnedAt: '2025-01-01T00:00:00.000Z' }, + ]); + + // Mock: process is dead + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => { + throw new Error('ESRCH'); + }) as typeof process.kill); + + const tracker = new PidTracker(teamRoot); + tracker.cleanupStale(); + + expect(pidFileExists(teamRoot)).toBe(false); + + killSpy.mockRestore(); + }); + }); +}); diff --git a/test/comms-teams-integration.test.ts b/test/comms-teams-integration.test.ts new file mode 100644 index 000000000..689b000ae --- /dev/null +++ b/test/comms-teams-integration.test.ts @@ -0,0 +1,544 @@ +/** + * Integration tests for Teams adapter class methods. + * + * These tests ensureAuthenticated, ensureChat, postUpdate, and pollForReplies + * with a mocked global `fetch` and mocked `node:fs` (for token persistence). + * The real Graph API is never contacted. + * + * Auth is bypassed for most tests by returning a valid cached token from fs. + * The token-refresh test uses an expired cached token so the adapter tries + * the refresh endpoint (a mocked fetch call) — browser auth is never reached. + * + * Closes #772 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ─── Module mocks (hoisted before adapter import) ──────────────────── + +const fsMocks = vi.hoisted(() => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + accessToken: 'cached-access-token', + refreshToken: 'cached-refresh-token', + expiresAt: Date.now() + 3_600_000, + }), + ), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + chmodSync: vi.fn(), +})); + +const cpMocks = vi.hoisted(() => ({ + execFile: vi.fn( + (_cmd: string, _args: string[], cb?: (err: Error | null) => void) => { + if (cb) cb(null); + }, + ), +})); + +const osMocks = vi.hoisted(() => ({ + platform: vi.fn(() => 'linux' as NodeJS.Platform), + homedir: vi.fn(() => '/mock-home'), +})); + +/** Minimal mock server that rejects immediately via the 'error' event. */ +const httpMocks = vi.hoisted(() => ({ + createServer: vi.fn((_handler?: unknown) => { + type Handler = (...args: unknown[]) => void; + const handlers: Record = {}; + return { + on(event: string, handler: Handler) { + (handlers[event] ??= []).push(handler); + return this; + }, + listen(..._args: unknown[]) { + // Fire the error handler on next tick (after it's registered) + process.nextTick(() => { + for (const h of handlers['error'] ?? []) { + h(new Error('mock: no browser in CI')); + } + }); + }, + close() {}, + address() { + return { port: 0 }; + }, + }; + }), +})); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { ...actual, ...fsMocks }; +}); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { ...actual, execFile: cpMocks.execFile }; +}); + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { ...actual, platform: osMocks.platform, homedir: osMocks.homedir }; +}); + +vi.mock('node:http', async () => { + const actual = await vi.importActual('node:http'); + return { ...actual, createServer: httpMocks.createServer }; +}); + +import { TeamsCommunicationAdapter } from '../packages/squad-sdk/src/platform/comms-teams.js'; + +// ─── Helpers ───────────────────────────────────────────────────────── + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// ─── Fixtures ──────────────────────────────────────────────────────── + +const ME_RESPONSE = { id: 'my-user-id', displayName: 'Test User' }; +const CHAT_RESPONSE = { id: '19:test-chat-id@thread.v2' }; +const MESSAGE_POST_RESPONSE = { id: 'msg-1' }; + +// ─── Setup / Teardown ──────────────────────────────────────────────── + +let fetchMock: ReturnType; +let logSpy: ReturnType; +let warnSpy: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Default: valid cached token (most tests skip auth entirely) + fsMocks.existsSync.mockReturnValue(true); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + accessToken: 'cached-access-token', + refreshToken: 'cached-refresh-token', + expiresAt: Date.now() + 3_600_000, + }), + ); + fsMocks.writeFileSync.mockReset(); + fsMocks.mkdirSync.mockReset(); + + // Default: execFile succeeds (no-op callback) + cpMocks.execFile.mockImplementation( + (_cmd: string, _args: string[], cb?: (err: Error | null) => void) => { + if (cb) cb(null); + }, + ); + + // Default: non-Windows platform + osMocks.platform.mockReturnValue('linux'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** Standard fetch mock for Graph API endpoints (auth already handled by cached tokens). */ +function setupGraphMock(overrides?: Partial Response>>) { + fetchMock.mockImplementation(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = + typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + + for (const [pattern, handler] of Object.entries(overrides ?? {})) { + if (urlStr.includes(pattern)) return handler!(); + } + + if (urlStr.includes('/v1.0/me') && !urlStr.includes('/chats')) return jsonResponse(ME_RESPONSE); + if (urlStr.includes('/v1.0/chats') && !urlStr.includes('/messages')) { + return jsonResponse(CHAT_RESPONSE); + } + if (urlStr.includes('/messages')) return jsonResponse(MESSAGE_POST_RESPONSE); + + // Token refresh endpoint (for the refresh test) + if (urlStr.includes('/oauth2/v2.0/token')) { + return jsonResponse({ + access_token: 'refreshed-access-token', + refresh_token: 'refreshed-refresh-token', + expires_in: 3600, + }); + } + + return jsonResponse({ error: 'unexpected' }, 404); + }); +} + +// ─── 1. Token refresh flow: expired → refresh → success ────────────── + +describe('ensureAuthenticated (via postUpdate)', () => { + it('refreshes an expired token via the refresh endpoint', async () => { + // Provide an EXPIRED cached token with a refresh token + fsMocks.existsSync.mockReturnValue(true); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + accessToken: 'expired-token', + refreshToken: 'valid-refresh', + expiresAt: 0, // expired + }), + ); + + const adapter = new TeamsCommunicationAdapter({ recipientUpn: 'alice@contoso.com' }); + let refreshCalled = false; + + fetchMock.mockImplementation(async (url: string | URL | Request) => { + const urlStr = + typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + + // Token refresh endpoint — the key assertion + if (urlStr.includes('/oauth2/v2.0/token')) { + refreshCalled = true; + return jsonResponse({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + }); + } + + if (urlStr.includes('/v1.0/me') && !urlStr.includes('/chats')) return jsonResponse(ME_RESPONSE); + if (urlStr.includes('/v1.0/chats') && !urlStr.includes('/messages')) return jsonResponse(CHAT_RESPONSE); + if (urlStr.includes('/messages')) return jsonResponse(MESSAGE_POST_RESPONSE); + return jsonResponse({}, 404); + }); + + const result = await adapter.postUpdate({ title: 'Refresh', body: 'Test' }); + + expect(refreshCalled).toBe(true); + expect(result.id).toBe('19:test-chat-id@thread.v2'); + expect(result.url).toContain('https://teams.microsoft.com/l/chat/'); + // Verify token was persisted + expect(fsMocks.writeFileSync).toHaveBeenCalled(); + }); + + it('reuses a valid cached token without re-authenticating', async () => { + const adapter = new TeamsCommunicationAdapter({ recipientUpn: 'alice@contoso.com' }); + setupGraphMock(); + + const result = await adapter.postUpdate({ title: 'Cached', body: 'No auth' }); + + expect(result.id).toBe('19:test-chat-id@thread.v2'); + // No token endpoint should have been called + const tokenCalls = fetchMock.mock.calls.filter( + (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/oauth2/'), + ); + expect(tokenCalls).toHaveLength(0); + }); +}); + +// ─── 3. postUpdate to chat vs channel ──────────────────────────────── + +describe('saveTokens — icacls warning on Windows (#770)', () => { + it('logs warning when icacls fails on Windows', async () => { + // Expired token → triggers refresh → saveTokens() runs on success + fsMocks.existsSync.mockReturnValue(true); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + accessToken: 'expired-token', + refreshToken: 'valid-refresh', + expiresAt: 0, + }), + ); + + // Platform = Windows → saveTokens takes the icacls branch + osMocks.platform.mockReturnValue('win32'); + + // execFile calls back with an error (simulates icacls failure) + cpMocks.execFile.mockImplementation( + (_cmd: string, _args: string[], cb?: (err: Error | null) => void) => { + if (cb) cb(new Error('Access is denied')); + }, + ); + + const adapter = new TeamsCommunicationAdapter({ recipientUpn: 'alice@contoso.com' }); + + fetchMock.mockImplementation(async (url: string | URL | Request) => { + const urlStr = + typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + if (urlStr.includes('/oauth2/v2.0/token')) { + return jsonResponse({ + access_token: 'new-token', + refresh_token: 'new-refresh', + expires_in: 3600, + }); + } + if (urlStr.includes('/v1.0/me') && !urlStr.includes('/chats')) return jsonResponse(ME_RESPONSE); + if (urlStr.includes('/v1.0/chats') && !urlStr.includes('/messages')) return jsonResponse(CHAT_RESPONSE); + if (urlStr.includes('/messages')) return jsonResponse(MESSAGE_POST_RESPONSE); + return jsonResponse({}, 404); + }); + + await adapter.postUpdate({ title: 'icacls', body: 'test' }); + + // Verify icacls was attempted + expect(cpMocks.execFile).toHaveBeenCalledWith( + 'icacls', + expect.arrayContaining([expect.stringContaining('.squad')]), + expect.any(Function), + ); + // Verify warning was logged + expect(warnSpy).toHaveBeenCalledWith( + '⚠️ Could not restrict token file permissions:', + 'Access is denied', + ); + }); +}); + +// ─── Token refresh failure → graceful fallback ─────────────────────── + +describe('token refresh failure fallback', () => { + it('warns on refresh failure and falls back to device code', async () => { + // Expired token with refresh token → adapter tries refresh first + fsMocks.existsSync.mockReturnValue(true); + fsMocks.readFileSync.mockReturnValue( + JSON.stringify({ + accessToken: 'expired-token', + refreshToken: 'stale-refresh', + expiresAt: 0, + }), + ); + + const adapter = new TeamsCommunicationAdapter({ recipientUpn: 'bob@contoso.com' }); + let refreshAttempted = false; + let deviceCodeCompleted = false; + + fetchMock.mockImplementation(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = + typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + + // Token endpoint — differentiate refresh vs device code by grant_type + if (urlStr.includes('/oauth2/v2.0/token')) { + const body = init?.body?.toString() ?? ''; + if (body.includes('grant_type=refresh_token')) { + refreshAttempted = true; + // Return error (no access_token) → triggers throw in refreshAccessToken + return jsonResponse({ + error: 'invalid_grant', + error_description: 'Refresh token expired', + }); + } + // Device code grant — success + deviceCodeCompleted = true; + return jsonResponse({ + access_token: 'device-code-token', + refresh_token: 'device-code-refresh', + expires_in: 3600, + }); + } + + // Device code initiation + if (urlStr.includes('/devicecode')) { + return jsonResponse({ + device_code: 'dc-fallback', + user_code: 'FALL-BACK', + verification_uri: 'https://microsoft.com/devicelogin', + expires_in: 900, + interval: 0.001, // near-instant polling for test speed + message: 'Authenticate', + }); + } + + // Graph API calls + if (urlStr.includes('/v1.0/me') && !urlStr.includes('/chats')) return jsonResponse(ME_RESPONSE); + if (urlStr.includes('/v1.0/chats') && !urlStr.includes('/messages')) return jsonResponse(CHAT_RESPONSE); + if (urlStr.includes('/messages')) return jsonResponse(MESSAGE_POST_RESPONSE); + return jsonResponse({}, 404); + }); + + const result = await adapter.postUpdate({ title: 'Fallback', body: 'Works' }); + + // Refresh was attempted and failed + expect(refreshAttempted).toBe(true); + // Warning logged about refresh failure + expect(warnSpy).toHaveBeenCalledWith('⚠️ Token refresh permanently failed (invalid_grant) — re-authenticating...'); + // Fell through to device code and succeeded + expect(deviceCodeCompleted).toBe(true); + expect(result.id).toBeDefined(); + }); +}); + +// ─── postUpdate to chat vs channel ─────────────────────────────────── + +describe('postUpdate — channel mode', () => { + it('posts to a team channel and returns encoded deep-link URL (#771)', async () => { + const adapter = new TeamsCommunicationAdapter({ + teamId: 'team-abc', + channelId: '19:channel@thread.tacv2', + }); + setupGraphMock(); + + const result = await adapter.postUpdate({ + title: 'Channel Post', + body: 'Team update', + author: 'EECOM', + }); + + expect(result.id).toBe('team-abc|19:channel@thread.tacv2'); + // #771: channelId MUST be URI-encoded in the deep-link + expect(result.url).toBe( + `https://teams.microsoft.com/l/channel/${encodeURIComponent('19:channel@thread.tacv2')}`, + ); + }); + + it('posts to a 1:1 chat and returns a chat deep-link URL', async () => { + const adapter = new TeamsCommunicationAdapter({ recipientUpn: 'bob@contoso.com' }); + setupGraphMock(); + + const result = await adapter.postUpdate({ title: 'Chat', body: 'Hello Bob' }); + + expect(result.id).toBe('19:test-chat-id@thread.v2'); + expect(result.url).toContain('https://teams.microsoft.com/l/chat/'); + }); +}); + +// ─── 4–5. pollForReplies ───────────────────────────────────────────── + +describe('pollForReplies', () => { + it('filters out own messages and returns others', async () => { + const adapter = new TeamsCommunicationAdapter({ chatId: '19:poll@thread.v2' }); + setupGraphMock({ + '/messages': () => + jsonResponse({ + value: [ + { + id: 'msg-own', + body: { content: '

My own message

' }, + from: { user: { displayName: 'Test User', id: 'my-user-id' } }, + createdDateTime: '2024-01-01T01:00:00Z', + }, + { + id: 'msg-other', + body: { content: '

Reply from Alice

' }, + from: { user: { displayName: 'Alice', id: 'alice-id' } }, + createdDateTime: '2024-01-01T02:00:00Z', + }, + ], + }), + }); + + const replies = await adapter.pollForReplies({ + threadId: '19:poll@thread.v2', + since: new Date('2024-01-01T00:00:00Z'), + }); + + expect(replies).toHaveLength(1); + expect(replies[0]!.author).toBe('Alice'); + expect(replies[0]!.body).toBe('Reply from Alice'); + expect(replies[0]!.id).toBe('msg-other'); + }); + + it('returns empty array and warns on Graph API error', async () => { + const adapter = new TeamsCommunicationAdapter({ chatId: '19:err@thread.v2' }); + setupGraphMock({ + '/messages': () => jsonResponse({ error: 'Internal Server Error' }, 500), + }); + + const replies = await adapter.pollForReplies({ + threadId: '19:err@thread.v2', + since: new Date(), + }); + + expect(replies).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('pollForReplies failed'), + ); + }); + + it('handles channel mode composite threadId', async () => { + const adapter = new TeamsCommunicationAdapter({}); + setupGraphMock({ + '/messages': () => + jsonResponse({ + value: [ + { + id: 'ch-msg-1', + body: { content: '

Channel reply

' }, + from: { user: { displayName: 'Dana', id: 'dana-id' } }, + createdDateTime: '2024-06-01T10:00:00Z', + }, + ], + }), + }); + + const replies = await adapter.pollForReplies({ + threadId: 'team-abc|19:channel@thread.tacv2', + since: new Date('2024-06-01T00:00:00Z'), + }); + + expect(replies).toHaveLength(1); + expect(replies[0]!.author).toBe('Dana'); + }); +}); + +// ─── 6. ensureChat: recipientUpn vs "me" mode ──────────────────────── + +describe('ensureChat (via postUpdate)', () => { + it('"me" mode with explicit chatId skips chat creation', async () => { + const adapter = new TeamsCommunicationAdapter({ + recipientUpn: 'me', + chatId: '19:explicit@thread.v2', + }); + + const graphCalls: string[] = []; + fetchMock.mockImplementation(async (url: string | URL | Request) => { + const urlStr = + typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + graphCalls.push(urlStr); + + if (urlStr.includes('/messages')) return jsonResponse(MESSAGE_POST_RESPONSE); + if (urlStr.includes('/v1.0/me')) return jsonResponse(ME_RESPONSE); + return jsonResponse({}, 404); + }); + + const result = await adapter.postUpdate({ title: 'Me', body: 'Self-chat' }); + expect(result.id).toBe('19:explicit@thread.v2'); + + // No POST to /chats (chat creation) should have happened + const chatCreationCalls = graphCalls.filter( + (u) => u.includes('/v1.0/chats') && !u.includes('/messages'), + ); + expect(chatCreationCalls).toHaveLength(0); + }); + + it('"me" mode without chatId throws descriptive error', async () => { + const adapter = new TeamsCommunicationAdapter({ recipientUpn: 'me' }); + setupGraphMock(); + + await expect( + adapter.postUpdate({ title: 'Fail', body: 'No chatId' }), + ).rejects.toThrow('requires an explicit chatId'); + }); + + it('recipientUpn mode creates a 1:1 chat via Graph POST', async () => { + const adapter = new TeamsCommunicationAdapter({ recipientUpn: 'carol@contoso.com' }); + let chatCreateCalled = false; + + fetchMock.mockImplementation(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = + typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + + if (urlStr.includes('/v1.0/me') && !urlStr.includes('/chats')) return jsonResponse(ME_RESPONSE); + if (urlStr.includes('/v1.0/chats') && !urlStr.includes('/messages')) { + chatCreateCalled = true; + expect(init?.method).toBe('POST'); + return jsonResponse(CHAT_RESPONSE); + } + if (urlStr.includes('/messages')) return jsonResponse(MESSAGE_POST_RESPONSE); + return jsonResponse({}, 404); + }); + + const result = await adapter.postUpdate({ title: 'UPN', body: '1:1 chat' }); + expect(result.id).toBe('19:test-chat-id@thread.v2'); + expect(chatCreateCalled).toBe(true); + }); +}); diff --git a/test/comms-teams-security.test.ts b/test/comms-teams-security.test.ts new file mode 100644 index 000000000..1c464aba5 --- /dev/null +++ b/test/comms-teams-security.test.ts @@ -0,0 +1,363 @@ +/** + * Security tests for Teams adapter token management. + * Covers: identity-scoped storage, logout, device-code timeout guards, + * stale token cleanup, legacy migration, JWT claim extraction, + * traversal protection, and identity cache resets. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createHash } from 'node:crypto'; +import { + getTokenPath, + clearTokens, + loadTokens, + saveTokens, + migrateLegacyTokens, + extractJwtClaims, + TeamsCommunicationAdapter, + DEVICE_CODE_TIMEOUT_MS, + DEVICE_CODE_MIN_POLL_MS, + DEVICE_CODE_MAX_POLL_MS, + LEGACY_TOKEN_PATH, + PERMANENT_AUTH_ERRORS, +} from '../packages/squad-sdk/src/platform/comms-teams.js'; + +// Default client ID from the source (Microsoft Graph PowerShell) +const DEFAULT_CLIENT_ID = '14d82eec-204b-4c2f-b7e8-296a70dab67e'; + +// ─── Mock node:fs so we never touch the real filesystem ────────────── + +const mockFiles = new Map(); + +vi.mock('node:fs', async () => { + return { + existsSync: (p: string) => mockFiles.has(p), + readFileSync: (p: string) => { + const content = mockFiles.get(p); + if (content === undefined) throw new Error(`ENOENT: ${p}`); + return content; + }, + writeFileSync: (p: string, data: string) => { + mockFiles.set(p, data); + }, + mkdirSync: () => {}, + chmodSync: () => {}, + unlinkSync: (p: string) => { + mockFiles.delete(p); + }, + }; +}); + +// Prevent icacls / chmod side effects +vi.mock('node:child_process', () => ({ + execFile: (_cmd: string, _args: string[], _cb: unknown) => {}, +})); + +beforeEach(() => { + mockFiles.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Build a fake JWT with the given payload claims for testing. */ +function fakeJwt(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + const signature = 'fake-sig'; + return `${header}.${payload}.${signature}`; +} + +// ─── getTokenPath — cache key design ───────────────────────────────── + +describe('getTokenPath — identity-scoped file paths', () => { + it('includes clientId in hash — different apps get different paths', () => { + const pathA = getTokenPath('organizations', 'client-aaa'); + const pathB = getTokenPath('organizations', 'client-bbb'); + expect(pathA).not.toBe(pathB); + }); + + it('includes tenantId in hash — different tenants get different paths', () => { + const pathA = getTokenPath('contoso.com', DEFAULT_CLIENT_ID); + const pathB = getTokenPath('fabrikam.com', DEFAULT_CLIENT_ID); + expect(pathA).not.toBe(pathB); + }); + + it('same config produces same path (deterministic)', () => { + expect(getTokenPath('t', 'c')).toBe(getTokenPath('t', 'c')); + }); + + it('stays under the ~/.squad directory', () => { + const p = getTokenPath('organizations', DEFAULT_CLIENT_ID); + expect(p).toContain('.squad'); + expect(p).toContain('teams-tokens-'); + }); + + it('traversal chars cannot escape ~/.squad', () => { + const p = getTokenPath('../../etc/passwd', '../../../root/.ssh/id_rsa'); + expect(p).not.toContain('..'); + expect(p).not.toContain('etc'); + expect(p).not.toContain('passwd'); + expect(p).not.toContain('root'); + expect(p).toContain('teams-tokens-'); + }); + + it('hash matches expected SHA-256(tenantId:clientId) prefix', () => { + const hash = createHash('sha256').update('test-tenant:test-client').digest('hex').slice(0, 16); + const p = getTokenPath('test-tenant', 'test-client'); + expect(p).toContain(`teams-tokens-${hash}.json`); + }); + + it('default "organizations" authority gets a valid unique path', () => { + const p = getTokenPath('organizations', DEFAULT_CLIENT_ID); + expect(p).toMatch(/teams-tokens-[a-f0-9]{16}\.json$/); + }); +}); + +// ─── Token storage: load / save / clear ────────────────────────────── + +describe('identity-scoped token CRUD', () => { + const tenantA = 'contoso.com'; + const tenantB = 'fabrikam.com'; + const client = DEFAULT_CLIENT_ID; + + const tokenA = { + accessToken: fakeJwt({ tid: 'contoso-guid', oid: 'user-a' }), + refreshToken: 'rt-contoso', + expiresAt: Date.now() + 3600_000, + }; + const tokenB = { + accessToken: fakeJwt({ tid: 'fabrikam-guid', oid: 'user-b' }), + refreshToken: 'rt-fabrikam', + expiresAt: Date.now() + 3600_000, + }; + + it('saves and loads tokens for a config', () => { + saveTokens(tenantA, client, tokenA); + const loaded = loadTokens(tenantA, client); + expect(loaded).not.toBeNull(); + expect(loaded!.refreshToken).toBe('rt-contoso'); + }); + + it('isolates tokens between different tenants', () => { + saveTokens(tenantA, client, tokenA); + saveTokens(tenantB, client, tokenB); + expect(loadTokens(tenantA, client)!.refreshToken).toBe('rt-contoso'); + expect(loadTokens(tenantB, client)!.refreshToken).toBe('rt-fabrikam'); + }); + + it('isolates tokens between different client IDs', () => { + saveTokens(tenantA, 'client-1', tokenA); + saveTokens(tenantA, 'client-2', tokenB); + expect(loadTokens(tenantA, 'client-1')!.refreshToken).toBe('rt-contoso'); + expect(loadTokens(tenantA, 'client-2')!.refreshToken).toBe('rt-fabrikam'); + }); + + it('returns null for unknown config', () => { + expect(loadTokens('unknown-tenant', client)).toBeNull(); + }); + + it('rejects tokens with mismatched configTenantId metadata', () => { + const path = getTokenPath(tenantA, client); + mockFiles.set(path, JSON.stringify({ ...tokenA, configTenantId: 'wrong-tenant', clientId: client })); + expect(loadTokens(tenantA, client)).toBeNull(); + }); + + it('rejects tokens with mismatched clientId metadata', () => { + const path = getTokenPath(tenantA, client); + mockFiles.set(path, JSON.stringify({ ...tokenA, configTenantId: tenantA, clientId: 'wrong-client' })); + expect(loadTokens(tenantA, client)).toBeNull(); + }); + + it('stores authenticatedTenantId from JWT on save', () => { + saveTokens(tenantA, client, tokenA); + const raw = JSON.parse(mockFiles.get(getTokenPath(tenantA, client))!); + expect(raw.authenticatedTenantId).toBe('contoso-guid'); + expect(raw.authenticatedUserId).toBe('user-a'); + }); + + it('clearTokens removes tokens from disk', () => { + saveTokens(tenantA, client, tokenA); + expect(loadTokens(tenantA, client)).not.toBeNull(); + clearTokens(tenantA, client); + expect(loadTokens(tenantA, client)).toBeNull(); + }); + + it('clearTokens is a no-op for missing files', () => { + expect(() => clearTokens('nonexistent', client)).not.toThrow(); + }); +}); + +// ─── extractJwtClaims ──────────────────────────────────────────────── + +describe('extractJwtClaims', () => { + it('extracts tid and oid from a valid JWT', () => { + const jwt = fakeJwt({ tid: 'my-tenant-guid', oid: 'my-user-guid', aud: 'graph' }); + const claims = extractJwtClaims(jwt); + expect(claims.tid).toBe('my-tenant-guid'); + expect(claims.oid).toBe('my-user-guid'); + }); + + it('returns empty object for non-JWT string', () => { + expect(extractJwtClaims('not-a-jwt')).toEqual({}); + }); + + it('returns empty object for malformed base64', () => { + expect(extractJwtClaims('a.!!!invalid!!!.c')).toEqual({}); + }); + + it('handles JWT without tid/oid claims', () => { + const jwt = fakeJwt({ sub: 'user', aud: 'graph' }); + const claims = extractJwtClaims(jwt); + expect(claims.tid).toBeUndefined(); + expect(claims.oid).toBeUndefined(); + }); + + it('ignores non-string tid/oid values', () => { + const jwt = fakeJwt({ tid: 123, oid: null }); + const claims = extractJwtClaims(jwt); + expect(claims.tid).toBeUndefined(); + expect(claims.oid).toBeUndefined(); + }); +}); + +// ─── Legacy migration ──────────────────────────────────────────────── + +describe('migrateLegacyTokens', () => { + it('migrates legacy token file to identity-scoped path', () => { + const legacyToken = { + accessToken: fakeJwt({ tid: 'legacy-tid', oid: 'legacy-oid' }), + refreshToken: 'legacy-refresh', + expiresAt: Date.now() + 3600_000, + }; + mockFiles.set(LEGACY_TOKEN_PATH, JSON.stringify(legacyToken)); + + migrateLegacyTokens('my-tenant', DEFAULT_CLIENT_ID); + + // Legacy file deleted + expect(mockFiles.has(LEGACY_TOKEN_PATH)).toBe(false); + // Token available at new path + const loaded = loadTokens('my-tenant', DEFAULT_CLIENT_ID); + expect(loaded).not.toBeNull(); + expect(loaded!.refreshToken).toBe('legacy-refresh'); + }); + + it('is a no-op when no legacy file exists', () => { + expect(() => migrateLegacyTokens('my-tenant', DEFAULT_CLIENT_ID)).not.toThrow(); + expect(loadTokens('my-tenant', DEFAULT_CLIENT_ID)).toBeNull(); + }); +}); + +// ─── Logout ────────────────────────────────────────────────────────── + +describe('TeamsCommunicationAdapter.logout()', () => { + it('clears tokens from disk after logout', async () => { + const tenantId = 'logout-test-tenant'; + saveTokens(tenantId, DEFAULT_CLIENT_ID, { + accessToken: fakeJwt({ tid: 'x', oid: 'y' }), + refreshToken: 'rt-logout', + expiresAt: Date.now() + 3600_000, + }); + + const adapter = new TeamsCommunicationAdapter({ tenantId }); + await adapter.logout(); + + expect(loadTokens(tenantId, DEFAULT_CLIENT_ID)).toBeNull(); + }); + + it('is safe to call multiple times', async () => { + const adapter = new TeamsCommunicationAdapter({ tenantId: 'double-logout' }); + await adapter.logout(); + await adapter.logout(); + // Should not throw + }); + + it('logout on one config does not affect another', async () => { + const tenantA = 'iso-tenant-a'; + const tenantB = 'iso-tenant-b'; + const client = DEFAULT_CLIENT_ID; + + saveTokens(tenantA, client, { + accessToken: fakeJwt({ tid: 'a-tid', oid: 'a-oid' }), + refreshToken: 'a-refresh', + expiresAt: Date.now() + 3600_000, + }); + saveTokens(tenantB, client, { + accessToken: fakeJwt({ tid: 'b-tid', oid: 'b-oid' }), + refreshToken: 'b-refresh', + expiresAt: Date.now() + 3600_000, + }); + + const adapterA = new TeamsCommunicationAdapter({ tenantId: tenantA }); + await adapterA.logout(); + + expect(loadTokens(tenantA, client)).toBeNull(); + expect(loadTokens(tenantB, client)).not.toBeNull(); + expect(loadTokens(tenantB, client)!.refreshToken).toBe('b-refresh'); + }); +}); + +// ─── Device-code timeout constants ─────────────────────────────────── + +describe('device-code timeout guards', () => { + it('max timeout is 15 minutes', () => { + expect(DEVICE_CODE_TIMEOUT_MS).toBe(15 * 60 * 1000); + }); + + it('min poll interval is 2 seconds', () => { + expect(DEVICE_CODE_MIN_POLL_MS).toBe(2_000); + }); + + it('max poll interval is 30 seconds', () => { + expect(DEVICE_CODE_MAX_POLL_MS).toBe(30_000); + }); +}); + +// ─── Permanent auth error classification ───────────────────────────── + +describe('PERMANENT_AUTH_ERRORS', () => { + it('includes invalid_grant', () => { + expect(PERMANENT_AUTH_ERRORS).toContain('invalid_grant'); + }); + + it('includes interaction_required', () => { + expect(PERMANENT_AUTH_ERRORS).toContain('interaction_required'); + }); + + it('includes consent_required', () => { + expect(PERMANENT_AUTH_ERRORS).toContain('consent_required'); + }); + + it('includes invalid_client', () => { + expect(PERMANENT_AUTH_ERRORS).toContain('invalid_client'); + }); + + it('does NOT include transient errors', () => { + expect(PERMANENT_AUTH_ERRORS).not.toContain('server_error'); + expect(PERMANENT_AUTH_ERRORS).not.toContain('temporarily_unavailable'); + expect(PERMANENT_AUTH_ERRORS).not.toContain('slow_down'); + }); +}); + +// ─── Multi-config adapter isolation ────────────────────────────────── + +describe('multi-config adapter isolation', () => { + it('different clientIds for same tenant produce different cache files', () => { + const pathA = getTokenPath('organizations', 'app-registration-1'); + const pathB = getTokenPath('organizations', 'app-registration-2'); + expect(pathA).not.toBe(pathB); + }); + + it('different tenantIds for same clientId produce different cache files', () => { + const pathA = getTokenPath('tenant-alpha', DEFAULT_CLIENT_ID); + const pathB = getTokenPath('tenant-beta', DEFAULT_CLIENT_ID); + expect(pathA).not.toBe(pathB); + }); + + it('default "organizations" + default clientId has a stable path', () => { + const p1 = getTokenPath('organizations', DEFAULT_CLIENT_ID); + const p2 = getTokenPath('organizations', DEFAULT_CLIENT_ID); + expect(p1).toBe(p2); + }); +}); diff --git a/test/docs-build.test.ts b/test/docs-build.test.ts index cc73d0ec4..832d868d5 100644 --- a/test/docs-build.test.ts +++ b/test/docs-build.test.ts @@ -302,6 +302,7 @@ describe('Docs Build Script (Astro)', () => { const html = readFile(indexPath); expect(html).toMatch(//i); expect(html).toContain('Development Team'); + expect(html).toContain('Humans stay in charge'); }); // --- 5. Blog index --- diff --git a/test/external-state.test.ts b/test/external-state.test.ts index ce8ae83a6..874dbacee 100644 --- a/test/external-state.test.ts +++ b/test/external-state.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { resolveExternalStateDir, deriveProjectKey } from '@bradygaster/squad-sdk'; import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { runExternalize, runInternalize } from '../packages/squad-cli/src/cli/commands/externalize.js'; const TEST_ROOT = path.join(os.tmpdir(), `squad-external-test-${Date.now()}`); @@ -121,3 +122,171 @@ describe('resolveExternalStateDir security', () => { expect(dir).not.toContain('/project\\'); }); }); + +// ============================================================================ +// runExternalize / runInternalize — CLI function tests +// ============================================================================ + +describe('runExternalize / runInternalize', () => { + let projectDir: string; + let squadDir: string; + let consoleSpy: ReturnType; + + beforeEach(() => { + projectDir = path.join(TEST_ROOT, 'fake-project'); + squadDir = path.join(projectDir, '.squad'); + mkdirSync(squadDir, { recursive: true }); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + /** Helper: list entries in a directory (returns [] if missing). */ + function lsDir(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir); + } + + // T1: runExternalize basic — files move, KEEP_LOCAL entries stay + it('moves state to external dir, leaving only KEEP_LOCAL entries', () => { + // Create typical .squad/ state + writeFileSync(path.join(squadDir, 'team.md'), '# Team\n'); + writeFileSync(path.join(squadDir, 'manifest.json'), '{"name":"test"}'); + writeFileSync(path.join(squadDir, 'workstreams.json'), '{"workstreams":[]}'); + writeFileSync(path.join(squadDir, 'upstream.json'), '{"upstreams":[]}'); + writeFileSync(path.join(squadDir, 'squad-registry.json'), '[]'); + const agentsDir = path.join(squadDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync(path.join(agentsDir, 'bot.md'), '# Bot\n'); + // _upstream_repos is a git clone cache that must stay local + const upstreamRepos = path.join(squadDir, '_upstream_repos', 'org'); + mkdirSync(upstreamRepos, { recursive: true }); + writeFileSync(path.join(upstreamRepos, 'README.md'), '# Upstream\n'); + + runExternalize(projectDir); + + // .squad/ should only retain KEEP_LOCAL entries + config.json (written by externalize) + const remaining = lsDir(squadDir).sort(); + expect(remaining).toContain('config.json'); + expect(remaining).toContain('manifest.json'); + expect(remaining).toContain('workstreams.json'); + expect(remaining).toContain('upstream.json'); + expect(remaining).toContain('squad-registry.json'); + expect(remaining).toContain('_upstream_repos'); + expect(remaining).not.toContain('team.md'); + expect(remaining).not.toContain('agents'); + + // External dir should have the moved files + const key = deriveProjectKey(projectDir); + const externalDir = resolveExternalStateDir(key, false); + expect(existsSync(path.join(externalDir, 'team.md'))).toBe(true); + expect(existsSync(path.join(externalDir, 'agents', 'bot.md'))).toBe(true); + // KEEP_LOCAL entries must NOT be externalized + expect(existsSync(path.join(externalDir, 'manifest.json'))).toBe(false); + expect(existsSync(path.join(externalDir, 'workstreams.json'))).toBe(false); + expect(existsSync(path.join(externalDir, 'upstream.json'))).toBe(false); + expect(existsSync(path.join(externalDir, 'squad-registry.json'))).toBe(false); + expect(existsSync(path.join(externalDir, '_upstream_repos'))).toBe(false); + }); + + // T2: runExternalize with unknown entries — proves dynamic scan + it('moves unknown entries not in old hardcoded lists', () => { + writeFileSync(path.join(squadDir, 'custom-state.json'), '{"x":1}'); + const pluginsDir = path.join(squadDir, 'plugins'); + mkdirSync(pluginsDir, { recursive: true }); + writeFileSync(path.join(pluginsDir, 'my-plugin.txt'), 'plugin data'); + + runExternalize(projectDir); + + const remaining = lsDir(squadDir); + expect(remaining).not.toContain('custom-state.json'); + expect(remaining).not.toContain('plugins'); + + const key = deriveProjectKey(projectDir); + const externalDir = resolveExternalStateDir(key, false); + expect(readFileSync(path.join(externalDir, 'custom-state.json'), 'utf-8')).toBe('{"x":1}'); + expect(readFileSync(path.join(externalDir, 'plugins', 'my-plugin.txt'), 'utf-8')).toBe('plugin data'); + }); + + // T3: runExternalize preserves existing config fields + it('preserves extra config.json fields through externalization', () => { + writeFileSync(path.join(squadDir, 'team.md'), '# Team\n'); + writeFileSync( + path.join(squadDir, 'config.json'), + JSON.stringify({ version: 1, consult: true, stateBackend: 'fs' }), + ); + + runExternalize(projectDir); + + const config = JSON.parse(readFileSync(path.join(squadDir, 'config.json'), 'utf-8')); + expect(config.consult).toBe(true); + expect(config.stateBackend).toBe('fs'); + expect(config.stateLocation).toBe('external'); + expect(config.projectKey).toBe(deriveProjectKey(projectDir)); + }); + + // T4: runInternalize round-trip — externalize then internalize restores files + it('round-trips files through externalize → internalize', () => { + writeFileSync(path.join(squadDir, 'team.md'), '# My Team\n'); + writeFileSync(path.join(squadDir, 'custom-data.json'), '{"round":"trip"}'); + const logDir = path.join(squadDir, 'log'); + mkdirSync(logDir, { recursive: true }); + writeFileSync(path.join(logDir, 'session.md'), '## Session 1\n'); + + runExternalize(projectDir); + + // State is gone from .squad/ + expect(existsSync(path.join(squadDir, 'team.md'))).toBe(false); + + runInternalize(projectDir); + + // State is back + expect(readFileSync(path.join(squadDir, 'team.md'), 'utf-8')).toBe('# My Team\n'); + expect(readFileSync(path.join(squadDir, 'custom-data.json'), 'utf-8')).toBe('{"round":"trip"}'); + expect(readFileSync(path.join(squadDir, 'log', 'session.md'), 'utf-8')).toBe('## Session 1\n'); + }); + + // T5: runInternalize cleans config — external-state fields removed + it('removes stateLocation/projectKey/teamRoot from config after internalize', () => { + // Seed config with an extra field so config.json survives (not deleted) + writeFileSync( + path.join(squadDir, 'config.json'), + JSON.stringify({ version: 1, consult: true }), + ); + writeFileSync(path.join(squadDir, 'team.md'), '# Team\n'); + + runExternalize(projectDir); + + // Verify external fields are present + const extConfig = JSON.parse(readFileSync(path.join(squadDir, 'config.json'), 'utf-8')); + expect(extConfig.stateLocation).toBe('external'); + expect(extConfig.projectKey).toBeDefined(); + expect(extConfig.teamRoot).toBe('.'); + + runInternalize(projectDir); + + // External fields must be gone; user fields preserved + const intConfig = JSON.parse(readFileSync(path.join(squadDir, 'config.json'), 'utf-8')); + expect(intConfig.stateLocation).toBeUndefined(); + expect(intConfig.projectKey).toBeUndefined(); + expect(intConfig.teamRoot).toBeUndefined(); + expect(intConfig.consult).toBe(true); + }); + + // T6: runExternalize on empty .squad/ — succeeds with nothing to move + it('succeeds when .squad/ contains only config.json', () => { + writeFileSync( + path.join(squadDir, 'config.json'), + JSON.stringify({ version: 1 }), + ); + + // Should not throw + expect(() => runExternalize(projectDir)).not.toThrow(); + + // config.json is still there with external marker + const config = JSON.parse(readFileSync(path.join(squadDir, 'config.json'), 'utf-8')); + expect(config.stateLocation).toBe('external'); + }); +}); diff --git a/test/helpers/skip-guards.ts b/test/helpers/skip-guards.ts new file mode 100644 index 000000000..2c79bbf52 --- /dev/null +++ b/test/helpers/skip-guards.ts @@ -0,0 +1,35 @@ +/** + * Shared test helpers for Docker-related skip guards and environment detection. + * + * Provides reusable functions for detecting Docker availability + * and determining whether Docker-dependent test suites should run. + */ + +import { execSync } from 'node:child_process'; + +/** + * Check if Docker is usable on this machine. + * Returns true if `docker info` succeeds within 5 seconds. + */ +export function isDockerAvailable(): boolean { + try { + execSync('docker info', { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } +} + +/** + * Determine skip reason for Docker-dependent tests. + * Returns null if tests should run, or a string reason to skip. + */ +export function dockerSkipReason(): string | null { + if (process.env['SKIP_DOCKER_TESTS'] === '1') { + return 'SKIP_DOCKER_TESTS=1'; + } + if (!isDockerAvailable()) { + return 'Docker not available'; + } + return null; +} diff --git a/test/init-autocast.test.ts b/test/init-autocast.test.ts index e46e1547b..ac6174dad 100644 --- a/test/init-autocast.test.ts +++ b/test/init-autocast.test.ts @@ -276,6 +276,79 @@ describe('orphan .init-prompt cleanup', () => { }); }); +// =========================================================================== +// 3b. finalizeCast empty-roster guard (PR #867) +// =========================================================================== + +describe('finalizeCast: empty-roster guard prevents dispatch loop', () => { + let tmpDir: string; + let squadDir: string; + + beforeEach(() => { + tmpDir = makeTempDir('finalize-guard-'); + squadDir = path.join(tmpDir, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + }); + + afterEach(() => { + cleanDir(tmpDir); + }); + + it('empty roster after createTeam cleans up .init-prompt and aborts', () => { + // Simulate: createTeam wrote a team.md with empty roster + .init-prompt exists + fs.writeFileSync(path.join(squadDir, 'team.md'), makeEmptyRosterTeamMd()); + fs.writeFileSync(path.join(squadDir, '.init-prompt'), 'Build a snake game'); + + const teamContent = fs.readFileSync(path.join(squadDir, 'team.md'), 'utf-8'); + + // Replicate the finalizeCast empty-roster guard from shell/index.ts + if (!hasRosterEntries(teamContent)) { + const initPromptPath = path.join(squadDir, '.init-prompt'); + if (fs.existsSync(initPromptPath)) { + try { fs.unlinkSync(initPromptPath); } catch { /* ignore */ } + } + // Guard fires: early return, no dispatch + } + + // .init-prompt must be cleaned up to prevent auto-retry loop + expect(fs.existsSync(path.join(squadDir, '.init-prompt'))).toBe(false); + // Roster is still empty — dispatch should NOT have happened + expect(hasRosterEntries(teamContent)).toBe(false); + }); + + it('populated roster after createTeam does NOT trigger guard', () => { + fs.writeFileSync( + path.join(squadDir, 'team.md'), + makePopulatedTeamMd([{ name: 'Fenster', role: 'Developer' }]), + ); + fs.writeFileSync(path.join(squadDir, '.init-prompt'), 'Build a snake game'); + + const teamContent = fs.readFileSync(path.join(squadDir, 'team.md'), 'utf-8'); + let guardFired = false; + + if (!hasRosterEntries(teamContent)) { + guardFired = true; + } + + // Guard should NOT fire — roster has entries, dispatch proceeds + expect(guardFired).toBe(false); + // .init-prompt should still exist (normal cleanup happens later in the flow) + expect(fs.existsSync(path.join(squadDir, '.init-prompt'))).toBe(true); + }); + + it('empty roster guard fires when team.md is missing entirely', () => { + // team.md doesn't exist → readSync returns '' → hasRosterEntries returns false + const teamContent = ''; + let guardFired = false; + + if (!hasRosterEntries(teamContent)) { + guardFired = true; + } + + expect(guardFired).toBe(true); + }); +}); + // =========================================================================== // 4. /init command — executeCommand('init', ...) // =========================================================================== diff --git a/test/init-scaffolding.test.ts b/test/init-scaffolding.test.ts index c73411488..3b180a812 100644 --- a/test/init-scaffolding.test.ts +++ b/test/init-scaffolding.test.ts @@ -218,11 +218,61 @@ describe('no-remote resilience (#579)', () => { expect(existsSync(join(TEST_ROOT, '.github', 'agents', 'squad.agent.md'))).toBe(true); }); + it('runInit CLI: monorepo subfolder — no nested .git, agent at git root (#939)', async () => { + // Set up a monorepo: git init at TEST_ROOT, run from a subfolder + gitInit(TEST_ROOT); + const subfolder = join(TEST_ROOT, 'services', 'api'); + await mkdir(subfolder, { recursive: true }); + + // runInit from the subfolder — CLI should detect the parent git repo + await expect(runInit(subfolder)).resolves.toBeUndefined(); + + // 1. No nested .git/ in subfolder (the original bug) + expect(existsSync(join(subfolder, '.git'))).toBe(false); + // 2. .squad/ created in the subfolder + expect(existsSync(join(subfolder, '.squad'))).toBe(true); + expect(existsSync(join(subfolder, '.squad', 'casting', 'registry.json'))).toBe(true); + // 3. squad.agent.md placed at the git root, not the subfolder + expect(existsSync(join(TEST_ROOT, '.github', 'agents', 'squad.agent.md'))).toBe(true); + expect(existsSync(join(subfolder, '.github', 'agents', 'squad.agent.md'))).toBe(false); + }); + it('initSquad succeeds when git is not initialized at all', async () => { // TEST_ROOT is a plain directory — no git init await expect(initSquad(sdkOptions(TEST_ROOT))).resolves.toBeDefined(); expect(existsSync(join(TEST_ROOT, '.squad', 'casting', 'registry.json'))).toBe(true); }); + + it('monorepo subfolder: no nested git init, agent at root, .squad in subfolder (#939)', async () => { + // Set up a monorepo with a subfolder + gitInit(TEST_ROOT); + const subfolder = join(TEST_ROOT, 'team-alpha'); + await mkdir(subfolder, { recursive: true }); + + // Run SDK init with agentFileRoot pointing to the git root + // Enable workflows to test monorepo skip behavior + const result = await initSquad({ + ...sdkOptions(subfolder), + agentFileRoot: TEST_ROOT, + includeWorkflows: true, + }); + + // 1. No nested .git/ in subfolder + expect(existsSync(join(subfolder, '.git'))).toBe(false); + // 2. .squad/ created in subfolder + expect(existsSync(join(subfolder, '.squad'))).toBe(true); + expect(existsSync(join(subfolder, '.squad', 'casting', 'registry.json'))).toBe(true); + // 3. squad.agent.md created at monorepo root + expect(existsSync(join(TEST_ROOT, '.github', 'agents', 'squad.agent.md'))).toBe(true); + // 4. squad.agent.md NOT in subfolder + expect(existsSync(join(subfolder, '.github', 'agents', 'squad.agent.md'))).toBe(false); + // 5. createdFiles should include relative path with .. + expect(result.createdFiles.some(f => f.includes('squad.agent.md'))).toBe(true); + // 6. Workflows NOT placed in subfolder (GitHub Actions ignores them there) + expect(existsSync(join(subfolder, '.github', 'workflows'))).toBe(false); + // 7. Warning emitted about skipped workflows + expect(result.warnings?.some(w => w.includes('monorepo-subfolder'))).toBe(true); + }); }); // ─── Doctor validation after init ────────────────────────────────────── diff --git a/test/scheduler.test.ts b/test/scheduler.test.ts index f9f3ac88c..cf22717bb 100644 --- a/test/scheduler.test.ts +++ b/test/scheduler.test.ts @@ -14,6 +14,7 @@ import { LocalPollingProvider, GitHubActionsProvider, ScheduleValidationError, + validateTaskRef, } from '../packages/squad-sdk/src/runtime/scheduler.js'; import type { ScheduleManifest, @@ -37,7 +38,7 @@ function validManifest(overrides?: Partial): ScheduleManifest name: 'Test Task', enabled: true, trigger: { type: 'interval', intervalSeconds: 60 }, - task: { type: 'script', ref: 'echo hello' }, + task: { type: 'script', ref: `${process.execPath} -e console.log('hello')` }, providers: ['local-polling'], }, ], @@ -51,7 +52,7 @@ function validEntry(overrides?: Partial): ScheduleEntry { name: 'Test Task', enabled: true, trigger: { type: 'interval', intervalSeconds: 60 }, - task: { type: 'script', ref: 'echo hello' }, + task: { type: 'script', ref: `${process.execPath} -e console.log('hello')` }, providers: ['local-polling'], ...overrides, }; @@ -413,7 +414,7 @@ describe('Scheduler: LocalPollingProvider', () => { it('should execute script tasks', async () => { const provider = new LocalPollingProvider(); const entry = validEntry({ - task: { type: 'script', ref: 'echo hello-from-scheduler' }, + task: { type: 'script', ref: `${process.execPath} -e console.log('hello-from-scheduler')` }, }); const result = await provider.execute(entry); expect(result.success).toBe(true); @@ -443,7 +444,7 @@ describe('Scheduler: LocalPollingProvider', () => { it('should handle script execution failure', async () => { const provider = new LocalPollingProvider(); const entry = validEntry({ - task: { type: 'script', ref: 'exit 1' }, + task: { type: 'script', ref: `${process.execPath} -e process.exit(1)` }, }); const result = await provider.execute(entry); expect(result.success).toBe(false); @@ -617,3 +618,125 @@ describe('Scheduler: Default Template', () => { expect(template.schedules[0]!.id).toBe('ralph-heartbeat'); }); }); + +// ============================================================================ +// Security: Shell Injection Prevention Tests +// ============================================================================ + +describe('Scheduler: Shell Injection Prevention', () => { + describe('validateTaskRef', () => { + it('should accept a valid script path', () => { + expect(() => validateTaskRef('./scripts/deploy.sh')).not.toThrow(); + expect(() => validateTaskRef(`${process.execPath} -e console.log(1)`)).not.toThrow(); + }); + + it('should reject null bytes', () => { + expect(() => validateTaskRef('script\x00injected')).toThrow('null bytes'); + }); + + it('should reject newline characters', () => { + expect(() => validateTaskRef('script\ninjected')).toThrow('newline'); + expect(() => validateTaskRef('script\rinjected')).toThrow('newline'); + }); + + it('should reject empty ref', () => { + expect(() => validateTaskRef('')).toThrow('non-empty'); + expect(() => validateTaskRef(' ')).toThrow('non-empty'); + }); + }); + + describe('manifest validation rejects dangerous script refs', () => { + it('should reject script refs with null bytes at parse time', () => { + const manifest = { + version: 1, + schedules: [{ + id: 'bad', name: 'Bad', enabled: true, + trigger: { type: 'interval', intervalSeconds: 60 }, + task: { type: 'script', ref: 'cmd\x00--evil' }, + providers: ['local-polling'], + }], + }; + expect(() => validateManifest(manifest)).toThrow('null bytes'); + }); + + it('should reject script refs with newlines at parse time', () => { + const manifest = { + version: 1, + schedules: [{ + id: 'bad', name: 'Bad', enabled: true, + trigger: { type: 'interval', intervalSeconds: 60 }, + task: { type: 'script', ref: 'cmd\n--evil' }, + providers: ['local-polling'], + }], + }; + expect(() => validateManifest(manifest)).toThrow('newline'); + }); + }); + + describe('LocalPollingProvider blocks injection via execFileSync', () => { + const provider = new LocalPollingProvider(); + + it('should not execute shell operators (semicolon injection)', async () => { + const entry = validEntry({ + task: { type: 'script', ref: `${process.execPath} -e console.log('safe'); echo INJECTED` }, + }); + const result = await provider.execute(entry); + // execFileSync passes '; echo INJECTED' as arguments to node, not as a shell command + // node will fail because the -e script has invalid syntax with the semicolon+args + // The key assertion: INJECTED should never appear in output as a separate command + if (result.success) { + expect(result.output).not.toContain('INJECTED'); + } + }); + + it('should not execute command substitution $()', async () => { + const entry = validEntry({ + task: { type: 'script', ref: `${process.execPath} -e $(whoami)` }, + }); + const result = await provider.execute(entry); + // Without shell, $(whoami) is passed as a literal string argument + if (result.success) { + expect(result.output).not.toMatch(/^[a-zA-Z]/); // Should not return a username + } + }); + + it('should not execute backtick injection', async () => { + const entry = validEntry({ + task: { type: 'script', ref: `${process.execPath} -e \`whoami\`` }, + }); + const result = await provider.execute(entry); + // Backticks are literal without shell interpretation + if (result.success) { + expect(result.output).not.toMatch(/^[a-zA-Z]/); + } + }); + + it('should not execute pipe injection', async () => { + const entry = validEntry({ + task: { type: 'script', ref: `${process.execPath} -e console.log('safe') | cat` }, + }); + const result = await provider.execute(entry); + // Without shell, '|' and 'cat' are just arguments to node + // This should either fail or not produce piped output + }); + + it('should not execute && chaining', async () => { + const entry = validEntry({ + task: { type: 'script', ref: `${process.execPath} -e console.log('safe') && echo INJECTED` }, + }); + const result = await provider.execute(entry); + if (result.output) { + expect(result.output).not.toContain('INJECTED'); + } + }); + + it('should reject null byte injection at runtime', async () => { + const entry = validEntry({ + task: { type: 'script', ref: `${process.execPath}\x00-e console.log('pwned')` }, + }); + const result = await provider.execute(entry); + expect(result.success).toBe(false); + expect(result.error).toContain('null bytes'); + }); + }); +}); diff --git a/test/state-backend.test.ts b/test/state-backend.test.ts index 40b47154a..f7d1d9d82 100644 --- a/test/state-backend.test.ts +++ b/test/state-backend.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node import { join } from 'node:path'; import { execSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import { WorktreeBackend, GitNotesBackend, OrphanBranchBackend, resolveStateBackend } from '../packages/squad-sdk/src/state-backend.js'; +import { WorktreeBackend, GitNotesBackend, OrphanBranchBackend, resolveStateBackend, validateStateKey } from '../packages/squad-sdk/src/state-backend.js'; import type { StateBackendType } from '../packages/squad-sdk/src/state-backend.js'; const TMP = join(process.cwd(), `.test-state-backend-${randomBytes(4).toString('hex')}`); @@ -93,4 +93,91 @@ describe('resolveStateBackend()', () => { it('all valid types accepted', () => { for (const t of ['worktree', 'external', 'git-notes', 'orphan'] as const) expect(resolveStateBackend(squadDir(), TMP, t)).toBeDefined(); }); +}); + +// ============================================================================ +// Security: Shell Injection Prevention Tests +// ============================================================================ + +describe('State Backend: validateStateKey', () => { + it('should accept valid keys', () => { + expect(() => validateStateKey('team.md')).not.toThrow(); + expect(() => validateStateKey('agents/data.md')).not.toThrow(); + expect(() => validateStateKey('deep/nested/path/file.json')).not.toThrow(); + }); + + it('should reject null bytes', () => { + expect(() => validateStateKey('key\x00injected')).toThrow('null bytes'); + }); + + it('should reject newline characters', () => { + expect(() => validateStateKey('key\ninjected')).toThrow('newline'); + expect(() => validateStateKey('key\rinjected')).toThrow('newline'); + }); + + it('should reject tab characters', () => { + expect(() => validateStateKey('key\tinjected')).toThrow('tab'); + }); + + it('should reject empty key', () => { + expect(() => validateStateKey('')).toThrow('non-empty'); + }); + + it('should reject path traversal with .. segments', () => { + expect(() => validateStateKey('../../../etc/passwd')).toThrow('. or ..'); + expect(() => validateStateKey('agents/../../../etc/passwd')).toThrow('. or ..'); + expect(() => validateStateKey('..')).toThrow('. or ..'); + }); + + it('should reject . segments', () => { + expect(() => validateStateKey('.')).toThrow('. or ..'); + expect(() => validateStateKey('agents/./data.md')).toThrow('. or ..'); + }); + + it('should reject empty path segments', () => { + expect(() => validateStateKey('agents//data.md')).toThrow('empty path segments'); + }); +}); + +describe('State Backend: Key injection blocked at backend level', () => { + beforeEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); initRepo(); }); + afterEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); }); + + it('GitNotesBackend rejects path traversal in write', () => { + const b = new GitNotesBackend(TMP); + expect(() => b.write('../../../etc/passwd', 'pwned')).toThrow('. or ..'); + }); + + it('GitNotesBackend rejects null bytes in read', () => { + const b = new GitNotesBackend(TMP); + expect(() => b.read('key\x00injected')).toThrow('null bytes'); + }); + + it('GitNotesBackend rejects tab injection in key', () => { + const b = new GitNotesBackend(TMP); + expect(() => b.write('key\tvalue', 'data')).toThrow('tab'); + }); + + it('OrphanBranchBackend rejects path traversal in write', { timeout: 10_000 }, () => { + const b = new OrphanBranchBackend(TMP); + expect(() => b.write('../../../etc/passwd', 'pwned')).toThrow('. or ..'); + }); + + it('OrphanBranchBackend rejects null bytes in read', () => { + const b = new OrphanBranchBackend(TMP); + expect(() => b.read('key\x00injected')).toThrow('null bytes'); + }); + + it('OrphanBranchBackend rejects newline injection in exists', () => { + const b = new OrphanBranchBackend(TMP); + expect(() => b.exists('key\ninjected')).toThrow('newline'); + }); + + it('WorktreeBackend normalizes and rejects traversal in write', () => { + const squadDir = join(TMP, '.squad'); + mkdirSync(squadDir, { recursive: true }); + const b = new WorktreeBackend(squadDir); + // WorktreeBackend uses path.join which handles traversal, but normalizeKey now validates + expect(() => b.write('../../../etc/passwd', 'pwned')).toThrow('. or ..'); + }); }); \ No newline at end of file diff --git a/test/watch-rate-limit.test.ts b/test/watch-rate-limit.test.ts new file mode 100644 index 000000000..1c03f6ede --- /dev/null +++ b/test/watch-rate-limit.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { reportBoard } from '../packages/squad-cli/src/cli/commands/watch/index.js'; +import type { BoardState } from '../packages/squad-cli/src/cli/commands/watch/index.js'; + +function emptyState(): BoardState { + return { untriaged: 0, assigned: 0, drafts: 0, needsReview: 0, changesRequested: 0, ciFailures: 0, readyToMerge: 0, executed: 0 }; +} + +describe('reportBoard rate-limit / error handling (#806)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows rate-limit warning instead of "Board is clear" when scanStatus is rate-limited', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + reportBoard(emptyState(), 1, { scanStatus: 'rate-limited' }); + const output = spy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('API rate limited'); + expect(output).not.toContain('Board is clear'); + }); + + it('shows error warning instead of "Board is clear" when scanStatus is error', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + reportBoard(emptyState(), 1, { scanStatus: 'error' }); + const output = spy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('Board scan failed'); + expect(output).not.toContain('Board is clear'); + }); + + it('shows normal "Board is clear" when scanStatus is ok', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + reportBoard(emptyState(), 1, { scanStatus: 'ok' }); + const output = spy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('Board is clear'); + }); + + it('shows normal "Board is clear" when scanStatus is omitted', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + reportBoard(emptyState(), 1); + const output = spy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('Board is clear'); + }); + + it('rate-limit warning bypasses notifyLevel "important" suppression', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + reportBoard(emptyState(), 1, { notifyLevel: 'important', scanStatus: 'rate-limited' }); + const output = spy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('API rate limited'); + }); + + it('error warning bypasses notifyLevel "important" suppression', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + reportBoard(emptyState(), 1, { notifyLevel: 'important', scanStatus: 'error' }); + const output = spy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('Board scan failed'); + }); + + it('rate-limit warning bypasses notifyLevel "none" suppression', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + reportBoard(emptyState(), 1, { notifyLevel: 'none', scanStatus: 'rate-limited' }); + const output = spy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('API rate limited'); + }); + + it('shows normal board when scanStatus is rate-limited but board is busy', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const busy = { ...emptyState(), untriaged: 3 }; + reportBoard(busy, 1, { scanStatus: 'rate-limited' }); + const output = spy.mock.calls.map(c => c.join(' ')).join('\n'); + // When there IS data, show the normal board (partial data is still useful) + expect(output).toContain('Round 1'); + expect(output).toContain('Untriaged'); + expect(output).not.toContain('API rate limited'); + }); +});