diff --git a/.ai-team/decisions/inbox/kobayashi-pr-123-opened.md b/.ai-team/decisions/inbox/kobayashi-pr-123-opened.md new file mode 100644 index 000000000..ae857ce50 --- /dev/null +++ b/.ai-team/decisions/inbox/kobayashi-pr-123-opened.md @@ -0,0 +1,43 @@ +# Decision: PR #123 Opened (Issues #86 & #87) + +**Date:** Today +**Agent:** Kobayashi (Git & Release Engineer) +**Status:** PENDING REVIEW +**PR:** https://github.com/bradygaster/squad/pull/123 + +## Context + +Brady requested that two completed issues be merged via PR to the `dev` branch: + +- **Issue #86:** Squad undid its own uncommitted changes (git safety regression) +- **Issue #87:** Squad workflows assume npm project type (project type detection) + +Both issues have been addressed in branch `squad/87-project-type-detection`. + +## Action Taken + +1. **Pushed branch to origin:** `squad/87-project-type-detection` (1 unpushed commit) +2. **Opened PR #123 against `dev`** with: + - Title: "feat: project type detection and git safety rules" + - Body: Issue closure refs, concise change summary, test confirmation + - Status: Awaiting Brady's review before merge + +## PR Details + +**URL:** https://github.com/bradygaster/squad/pull/123 +**Source:** squad/87-project-type-detection +**Target:** dev +**Commits:** 1 +**Tests:** 64+ pass + +## What Ships + +1. **Project type detection** — `detectProjectType()` and `generateProjectWorkflowStub()` for non-npm projects +2. **Git safety rules** — Spawn template safety rules prevent undoing uncommitted user changes +3. **Regression tests** — `test/git-safety.test.js` validates both features + +## Next Steps + +- Brady reviews PR #123 +- Merge to dev when approved +- No immediate action needed from Kobayashi pending review results diff --git a/.copilot/mcp-config.json b/.copilot/mcp-config.json new file mode 100644 index 000000000..ff2159ec1 --- /dev/null +++ b/.copilot/mcp-config.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "EXAMPLE-trello": { + "command": "npx", + "args": [ + "-y", + "@trello/mcp-server" + ], + "env": { + "TRELLO_API_KEY": "${TRELLO_API_KEY}", + "TRELLO_TOKEN": "${TRELLO_TOKEN}" + } + } + } +} diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index d8caf1663..41086c5d7 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -222,7 +222,7 @@ The routing table determines **WHO** handles work. After routing, use Response M | Issues/backlog request ("pull issues", "show backlog", "work on #N") | Follow GitHub Issues Mode (see that section) | | PRD intake ("here's the PRD", "read the PRD at X", pastes spec) | Follow PRD Mode (see that section) | | Human member management ("add Brady as PM", routes to human) | Follow Human Team Members (see that section) | -| Ralph commands ("Ralph, go", "keep working", "Ralph, status", "Ralph, idle", "Ralph, check every N minutes") | Follow Ralph — Work Monitor (see that section) | +| Ralph commands ("Ralph, go", "keep working", "Ralph, status", "Ralph, idle") | Follow Ralph — Work Monitor (see that section) | | General work request | Check routing.md, spawn best match + any anticipatory agents | | Quick factual question | Answer directly (no spawn) | | Ambiguous | Pick the most likely agent; say who you chose | @@ -730,7 +730,7 @@ prompt: | 5. **Immediately assess:** Does anything trigger follow-up work? Launch it NOW. -6. **Ralph check:** If Ralph is active, IMMEDIATELY run Ralph's work-check cycle. Do NOT wait for user input. +6. **Ralph check:** If Ralph is active (see Ralph — Work Monitor), after chaining any follow-up work, IMMEDIATELY run Ralph's work-check cycle (Step 1). Do NOT stop. Do NOT wait for user input. Ralph keeps the pipeline moving until the board is clear. ### Ceremonies @@ -949,7 +949,9 @@ Before connecting to a GitHub repository, verify that the `gh` CLI is available Ralph is a built-in squad member whose job is keeping tabs on work. **Ralph tracks and drives the work queue.** Always on the roster, one job: make sure the team never sits idle. -**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous loop — scan, work, scan, repeat — until the board is empty or the user says "idle"/"stop". When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** +**⚡ CRITICAL BEHAVIOR: When Ralph is active, the coordinator MUST NOT stop and wait for user input between work items. Ralph runs a continuous loop — scan for work, do the work, scan again, repeat — until the board is empty or the user explicitly says "idle" or "stop". This is not optional. If work exists, keep going. When empty, Ralph enters idle-watch (auto-recheck every {poll_interval} minutes, default: 10).** + +**Between checks:** Ralph's in-session loop runs while work exists. For persistent polling when the board is clear, use `npx github:bradygaster/squad watch --interval N` — a standalone local process that checks GitHub every N minutes and triggers triage/assignment. See [Watch Mode](#watch-mode-squad-watch). **On-demand reference:** Read `.squad/templates/ralph-reference.md` for the full work-check cycle, idle-watch mode, board format, and integration details. @@ -961,17 +963,127 @@ Ralph always appears in `team.md`: `| Ralph | Work Monitor | — | 🔄 Monitor | User says | Action | |-----------|--------| -| "Ralph, go" / "keep working" | Activate work-check loop | -| "Ralph, status" / "what's on the board?" | One cycle, report, don't loop | +| "Ralph, go" / "Ralph, start monitoring" / "keep working" | Activate work-check loop | +| "Ralph, status" / "What's on the board?" / "How's the backlog?" | Run one work-check cycle, report results, don't loop | | "Ralph, check every N minutes" | Set idle-watch polling interval | -| "Ralph, idle" / "stop monitoring" | Fully deactivate (stop loop + idle-watch) | -| "Ralph, scope: just issues" | Adjust monitoring scope this session | +| "Ralph, idle" / "Take a break" / "Stop monitoring" | Fully deactivate (stop loop + idle-watch) | +| "Ralph, scope: just issues" / "Ralph, skip CI" | Adjust what Ralph monitors this session | | References PR feedback or changes requested | Spawn agent to address PR review feedback | | "merge PR #N" / "merge it" (recent context) | Merge via `gh pr merge` | These are intent signals, not exact strings — match meaning, not words. -**Ralph does NOT ask "should I continue?" — Ralph KEEPS GOING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. +When Ralph is active, run this check cycle after every batch of agent work completes (or immediately on activation): + +**Step 1 — Scan for work** (run these in parallel): + +```bash +# Untriaged issues (labeled squad but no squad:{member} sub-label) +gh issue list --label "squad" --state open --json number,title,labels,assignees --limit 20 + +# Member-assigned issues (labeled squad:{member}, still open) +gh issue list --state open --json number,title,labels,assignees --limit 20 | # filter for squad:* labels + +# Open PRs from squad members +gh pr list --state open --json number,title,author,labels,isDraft,reviewDecision --limit 20 + +# Draft PRs (agent work in progress) +gh pr list --state open --draft --json number,title,author,labels,checks --limit 20 +``` + +**Step 2 — Categorize findings:** + +| Category | Signal | Action | +|----------|--------|--------| +| **Untriaged issues** | `squad` label, no `squad:{member}` label | Lead triages: reads issue, assigns `squad:{member}` label | +| **Assigned but unstarted** | `squad:{member}` label, no assignee or no PR | Spawn the assigned agent to pick it up | +| **Draft PRs** | PR in draft from squad member | Check if agent needs to continue; if stalled, nudge | +| **Review feedback** | PR has `CHANGES_REQUESTED` review | Route feedback to PR author agent to address | +| **CI failures** | PR checks failing | Notify assigned agent to fix, or create a fix issue | +| **Approved PRs** | PR approved, CI green, ready to merge | Merge and close related issue | +| **No work found** | All clear | Report: "📋 Board is clear. Ralph is idling." Suggest `npx github:bradygaster/squad watch` for persistent polling. | + +**Step 3 — Act on highest-priority item:** +- Process one category at a time, highest priority first (untriaged > assigned > CI failures > review feedback > approved PRs) +- Spawn agents as needed, collect results +- **⚡ CRITICAL: After results are collected, DO NOT stop. DO NOT wait for user input. IMMEDIATELY go back to Step 1 and scan again.** This is a loop — Ralph keeps cycling until the board is clear or the user says "idle". Each cycle is one "round". +- If multiple items exist in the same category, process them in parallel (spawn multiple agents) + +**Step 4 — Periodic check-in** (every 3-5 rounds): + +After every 3-5 rounds, pause and report before continuing: + +``` +🔄 Ralph: Round {N} complete. + ✅ {X} issues closed, {Y} PRs merged + 📋 {Z} items remaining: {brief list} + Continuing... (say "Ralph, idle" to stop) +``` + +**Do NOT ask for permission to continue.** Just report and keep going. The user must explicitly say "idle" or "stop" to break the loop. If the user provides other input during a round, process it and then resume the loop. + +### Watch Mode (`squad watch`) + +Ralph's in-session loop processes work while it exists, then idles. For **persistent polling** between sessions or when you're away from the keyboard, use the `squad watch` CLI command: + +```bash +npx github:bradygaster/squad watch # polls every 10 minutes (default) +npx github:bradygaster/squad watch --interval 5 # polls every 5 minutes +npx github:bradygaster/squad watch --interval 30 # polls every 30 minutes +``` + +This runs as a standalone local process (not inside Copilot) that: +- Checks GitHub every N minutes for untriaged squad work +- Auto-triages issues based on team roles and keywords +- Assigns @copilot to `squad:copilot` issues (if auto-assign is enabled) +- Runs until Ctrl+C + +**Three layers of Ralph:** + +| Layer | When | How | +|-------|------|-----| +| **In-session** | You're at the keyboard | "Ralph, go" — active loop while work exists | +| **Local watchdog** | You're away but machine is on | `npx github:bradygaster/squad watch --interval 10` | +| **Cloud heartbeat** | Fully unattended | `squad-heartbeat.yml` GitHub Actions cron | + +### Ralph State + +Ralph's state is session-scoped (not persisted to disk): +- **Active/idle** — whether the loop is running +- **Round count** — how many check cycles completed +- **Scope** — what categories to monitor (default: all) +- **Stats** — issues closed, PRs merged, items processed this session + +### Ralph on the Board + +When Ralph reports status, use this format: + +``` +🔄 Ralph — Work Monitor +━━━━━━━━━━━━━━━━━━━━━━ +📊 Board Status: + 🔴 Untriaged: 2 issues need triage + 🟡 In Progress: 3 issues assigned, 1 draft PR + 🟢 Ready: 1 PR approved, awaiting merge + ✅ Done: 5 issues closed this session + +Next action: Triaging #42 — "Fix auth endpoint timeout" +``` + +### Integration with Follow-Up Work + +After the coordinator's step 6 ("Immediately assess: Does anything trigger follow-up work?"), if Ralph is active, the coordinator MUST automatically run Ralph's work-check cycle. **Do NOT return control to the user.** This creates a continuous pipeline: + +1. User activates Ralph → work-check cycle runs +2. Work found → agents spawned → results collected +3. Follow-up work assessed → more agents if needed +4. Ralph scans GitHub again (Step 1) → IMMEDIATELY, no pause +5. More work found → repeat from step 2 +6. No more work → "📋 Board is clear. Ralph is idling." (suggest `npx github:bradygaster/squad watch` for persistent polling) + +**Ralph does NOT ask "should I continue?" — Ralph KEEPS GOING.** Only stops on explicit "idle"/"stop" or session end. A clear board → idle-watch, not full stop. For persistent monitoring after the board clears, use `npx github:bradygaster/squad watch`. + +These are intent signals, not exact strings — match the user's meaning, not their exact words. ### Connecting to a Repo diff --git a/.github/workflows/squad-main-guard.yml b/.github/workflows/squad-main-guard.yml index 2ae1e7fa5..7ea0dbe17 100644 --- a/.github/workflows/squad-main-guard.yml +++ b/.github/workflows/squad-main-guard.yml @@ -69,36 +69,36 @@ jobs: } // Check each file against forbidden path rules - // Allow removals — deleting forbidden files from protected branches is fine + // Allow removals ΓÇö deleting forbidden files from protected branches is fine const forbidden = files .filter(f => f.status !== 'removed') .map(f => f.filename) .filter(f => { - // .ai-team/** and .squad/** — ALL team state files, zero exceptions + // .ai-team/** and .squad/** ΓÇö ALL team state files, zero exceptions if (f === '.ai-team' || f.startsWith('.ai-team/') || f === '.squad' || f.startsWith('.squad/')) return true; - // .ai-team-templates/** — Squad's own templates, stay on dev + // .ai-team-templates/** ΓÇö Squad's own templates, stay on dev if (f === '.ai-team-templates' || f.startsWith('.ai-team-templates/')) return true; - // team-docs/** — ALL internal team docs, zero exceptions + // team-docs/** ΓÇö ALL internal team docs, zero exceptions if (f.startsWith('team-docs/')) return true; - // docs/proposals/** — internal design proposals, stay on dev + // docs/proposals/** ΓÇö internal design proposals, stay on dev if (f.startsWith('docs/proposals/')) return true; return false; }); if (forbidden.length === 0) { - core.info('✅ No forbidden paths found in PR — all clear.'); + core.info('Γ£à No forbidden paths found in PR ΓÇö all clear.'); return; } // Build a clear, actionable error message const lines = [ - '## 🚫 Forbidden files detected in PR to main', + '## ≡ƒÜ½ Forbidden files detected in PR to main', '', 'The following files must NOT be merged into `main`.', - '`.ai-team/` and `.squad/` are runtime team state — they belong on dev branches only.', - '`.ai-team-templates/` is Squad\'s internal planning — it belongs on dev branches only.', - '`team-docs/` is internal team content — it belongs on dev branches only.', - '`docs/proposals/` is internal design proposals — it belongs on dev branches only.', + '`.ai-team/` and `.squad/` are runtime team state ΓÇö they belong on dev branches only.', + '`.ai-team-templates/` is Squad\'s internal planning ΓÇö it belongs on dev branches only.', + '`team-docs/` is internal team content ΓÇö it belongs on dev branches only.', + '`docs/proposals/` is internal design proposals ΓÇö it belongs on dev branches only.', '', '### Forbidden files found:', '', @@ -121,7 +121,7 @@ jobs: 'git push', '```', '', - '> ⚠️ `.ai-team/` and `.squad/` are committed on `dev` and feature branches by design.', + '> ΓÜá∩╕Å `.ai-team/` and `.squad/` are committed on `dev` and feature branches by design.', '> The guard workflow is the enforcement mechanism that keeps these files off `main` and `preview`.', '> `git rm --cached` untracks them from this PR without deleting your local copies.', ]; diff --git a/.gitignore b/.gitignore index 1ca9b2693..78cbb32d7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,10 @@ node_modules/ # Built docs site (generated, not committed) _site/ -# Squad team state — committed on dev and feature branches, +# Squad team state ΓÇö committed on dev and feature branches, # blocked from main/preview by squad-main-guard.yml workflow. -# ⚠️ NEVER merge .ai-team/ into main or preview. +# ΓÜá∩╕Å NEVER merge .ai-team/ into main or preview. # The guard workflow will fail the PR if you try. + +# Squad internal templates (not distributed) +.ai-team-templates/ diff --git a/.squad/ceremonies.md b/.squad/ceremonies.md new file mode 100644 index 000000000..45b4a581a --- /dev/null +++ b/.squad/ceremonies.md @@ -0,0 +1,41 @@ +# Ceremonies + +> Team meetings that happen before or after work. Each squad configures their own. + +## Design Review + +| Field | Value | +|-------|-------| +| **Trigger** | auto | +| **When** | before | +| **Condition** | multi-agent task involving 2+ agents modifying shared systems | +| **Facilitator** | lead | +| **Participants** | all-relevant | +| **Time budget** | focused | +| **Enabled** | ✅ yes | + +**Agenda:** +1. Review the task and requirements +2. Agree on interfaces and contracts between components +3. Identify risks and edge cases +4. Assign action items + +--- + +## Retrospective + +| Field | Value | +|-------|-------| +| **Trigger** | auto | +| **When** | after | +| **Condition** | build failure, test failure, or reviewer rejection | +| **Facilitator** | lead | +| **Participants** | all-involved | +| **Time budget** | focused | +| **Enabled** | ✅ yes | + +**Agenda:** +1. What happened? (facts only) +2. Root cause analysis +3. What should change? +4. Action items for next iteration diff --git a/.squad/decisions/inbox/kobayashi-v0.5.0-release.md b/.squad/decisions/inbox/kobayashi-v0.5.0-release.md new file mode 100644 index 000000000..4718a2ba7 --- /dev/null +++ b/.squad/decisions/inbox/kobayashi-v0.5.0-release.md @@ -0,0 +1,54 @@ +# v0.5.0 Release Report +**Date:** 2026-02-21 +**Released by:** Kobayashi (DevOps) +**Requested by:** Brady + +## Summary +Successfully executed v0.5.0 release following the merge dev → preview → main workflow. All tests passing (64/64). Release artifact published to npm. + +## What Was Merged + +### dev → preview merge +- **Commit:** cce0309 +- **Message:** "chore: merge dev into preview for v0.5.0 release" +- **Conflicts resolved:** Removed dev-specific .ai-team/ and .squad/ files from preview branch (keeping preview's clean state) + - Removed 11 .ai-team/ conflict files + - Removed 13 .squad/templates/ conflict files +- **Push:** Successful to origin/preview + +### preview → main merge +- **Commit:** b3e1ac4 +- **Message:** "chore: release v0.5.0 — .squad/ migration, architectural cleanup, and quality improvements." +- **Conflicts resolved:** + - package.json: Accepted preview's v0.5.0 version + - .github/agents/squad.agent.md: Accepted preview's refactored casting reference structure (moved detailed allowlist to .squad/templates/casting-reference.md) +- **Push:** Successful to origin/main + +## Release Tag +- **Tag:** v0.5.0 +- **Message:** "v0.5.0 — .squad/ Migration & Architectural Cleanup" +- **Pushed:** Successfully to origin + +## CI/CD Workflows Triggered + +| Workflow | Status | ID | Branch | +|----------|--------|----|----| +| Squad Protected Branch Check | Running (X) | 22217443729 | main | +| Squad Release (npm publish) | In Progress (*) | 22217443709 | main | +| Squad Heartbeat (Ralph) | Passed (✓) | 22217425866 | main | +| Squad Preview Validation | Running (X) | 22217410668 | preview | +| Squad Protected Branch Check | Running (X) | 22217410661 | preview | +| Squad Docs — Build & Deploy | Passed (✓) | 22217410655 | preview | + +## Release Verification +- ✅ All tests passing on dev before merge (64/64) +- ✅ dev → preview merge completed +- ✅ preview → main merge completed +- ✅ v0.5.0 tag created and pushed +- ✅ Release workflows fired (npm publish in progress) +- ✅ No merge conflicts that couldn't be resolved cleanly + +## Next Steps +- Monitor Squad Release workflow (npm publish) for completion +- Verify npm package @bradygaster/create-squad@0.5.0 is live +- Update release notes/changelog on GitHub diff --git a/.squad/identity/now.md b/.squad/identity/now.md new file mode 100644 index 000000000..99e6ad6d7 --- /dev/null +++ b/.squad/identity/now.md @@ -0,0 +1,9 @@ +--- +updated_at: 2026-02-20T08:40:06.646Z +focus_area: Initial setup +active_issues: [] +--- + +# What We're Focused On + +Getting started. Updated by coordinator at session start. diff --git a/.squad/identity/wisdom.md b/.squad/identity/wisdom.md new file mode 100644 index 000000000..e78bee03d --- /dev/null +++ b/.squad/identity/wisdom.md @@ -0,0 +1,15 @@ +--- +last_updated: 2026-02-20T08:40:06.647Z +--- + +# Team Wisdom + +Reusable patterns and heuristics learned through work. NOT transcripts — each entry is a distilled, actionable insight. + +## Patterns + + + +## Anti-Patterns + + diff --git a/.squad/skills/squad-conventions/SKILL.md b/.squad/skills/squad-conventions/SKILL.md new file mode 100644 index 000000000..72eca68ed --- /dev/null +++ b/.squad/skills/squad-conventions/SKILL.md @@ -0,0 +1,69 @@ +--- +name: "squad-conventions" +description: "Core conventions and patterns used in the Squad codebase" +domain: "project-conventions" +confidence: "high" +source: "manual" +--- + +## Context +These conventions apply to all work on the Squad CLI tool (`create-squad`). Squad is a zero-dependency Node.js package that adds AI agent teams to any project. Understanding these patterns is essential before modifying any Squad source code. + +## Patterns + +### Zero Dependencies +Squad has zero runtime dependencies. Everything uses Node.js built-ins (`fs`, `path`, `os`, `child_process`). Do not add packages to `dependencies` in `package.json`. This is a hard constraint, not a preference. + +### Node.js Built-in Test Runner +Tests use `node:test` and `node:assert/strict` — no test frameworks. Run with `npm test`. Test files live in `test/`. The test command is `node --test test/`. + +### Error Handling — `fatal()` Pattern +All user-facing errors use the `fatal(msg)` function which prints a red `✗` prefix and exits with code 1. Never throw unhandled exceptions or print raw stack traces. The global `uncaughtException` handler calls `fatal()` as a safety net. + +### ANSI Color Constants +Colors are defined as constants at the top of `index.js`: `GREEN`, `RED`, `DIM`, `BOLD`, `RESET`. Use these constants — do not inline ANSI escape codes. + +### File Structure +- `.squad/` — Team state (user-owned, never overwritten by upgrades) +- `.squad/templates/` — Template files copied from `templates/` (Squad-owned, overwritten on upgrade) +- `.github/agents/squad.agent.md` — Coordinator prompt (Squad-owned, overwritten on upgrade) +- `templates/` — Source templates shipped with the npm package +- `.squad/skills/` — Team skills in SKILL.md format (user-owned) +- `.squad/decisions/inbox/` — Drop-box for parallel decision writes + +### Windows Compatibility +Always use `path.join()` for file paths — never hardcode `/` or `\` separators. Squad must work on Windows, macOS, and Linux. All tests must pass on all platforms. + +### Init Idempotency +The init flow uses a skip-if-exists pattern: if a file or directory already exists, skip it and report "already exists." Never overwrite user state during init. The upgrade flow overwrites only Squad-owned files. + +### Copy Pattern +`copyRecursive(src, target)` handles both files and directories. It creates parent directories with `{ recursive: true }` and uses `fs.copyFileSync` for files. + +## Examples + +```javascript +// Error handling +function fatal(msg) { + console.error(`${RED}✗${RESET} ${msg}`); + process.exit(1); +} + +// File path construction (Windows-safe) +const agentDest = path.join(dest, '.github', 'agents', 'squad.agent.md'); + +// Skip-if-exists pattern +if (!fs.existsSync(ceremoniesDest)) { + fs.copyFileSync(ceremoniesSrc, ceremoniesDest); + console.log(`${GREEN}✓${RESET} .squad/ceremonies.md`); +} else { + console.log(`${DIM}ceremonies.md already exists — skipping${RESET}`); +} +``` + +## Anti-Patterns +- **Adding npm dependencies** — Squad is zero-dep. Use Node.js built-ins only. +- **Hardcoded path separators** — Never use `/` or `\` directly. Always `path.join()`. +- **Overwriting user state on init** — Init skips existing files. Only upgrade overwrites Squad-owned files. +- **Raw stack traces** — All errors go through `fatal()`. Users see clean messages, not stack traces. +- **Inline ANSI codes** — Use the color constants (`GREEN`, `RED`, `DIM`, `BOLD`, `RESET`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50ee55b07..628cd279d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ # Contributing to Squad -Squad is built by contributors who believe in democratizing multi-agent development. We're excited to have you join us — and we want to make contributing as smooth as possible. +Squad is built by contributors who believe in democratizing multi-agent development. We're excited to have you join us ΓÇö and we want to make contributing as smooth as possible. -This guide covers everything you need to know: how to set up your environment, the branch model that keeps us sane, what files go where, and how to submit your work. **Pay special attention to the branch protection rules** — we protect `main` and `preview` aggressively, and it's easier to get it right the first time. +This guide covers everything you need to know: how to set up your environment, the branch model that keeps us sane, what files go where, and how to submit your work. **Pay special attention to the branch protection rules** ΓÇö we protect `main` and `preview` aggressively, and it's easier to get it right the first time. --- @@ -10,9 +10,9 @@ This guide covers everything you need to know: how to set up your environment, t ### Prerequisites -- **Node.js 22.0.0 or later** — required by the `engines` field in package.json -- **Git** — for cloning and branching -- **GitHub CLI (`gh`)** — for interactions with Issues, PRs, and (optionally) Project Boards +- **Node.js 22.0.0 or later** ΓÇö required by the `engines` field in package.json +- **Git** ΓÇö for cloning and branching +- **GitHub CLI (`gh`)** ΓÇö for interactions with Issues, PRs, and (optionally) Project Boards ### 1. Fork and Clone @@ -45,27 +45,27 @@ All tests should pass. If anything fails, [open an issue](https://github.com/bra Squad uses a three-tier branch structure to protect production and staging while keeping development flexible. ``` -┌─────────────────────────────────────────────────────────────────────┐ -│ │ -│ dev ── Feature branches ──→ dev ── (merge/rebase) ──→ preview │ -│ (squad/{issue}-{slug}) ╲ │ │ -│ └──→ Release tagged ───→ main │ -│ │ │ -│ ✅ ALL files allowed 🚫 .squad/ BLOCKED 🚫 BLOCKED │ -│ (dev branch = safe sandbox) team-docs/ BLOCKED (except │ -│ (except blog/) tagged │ -│ releases) │ -└─────────────────────────────────────────────────────────────────────┘ +ΓöîΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÉ +Γöé Γöé +Γöé dev ΓöÇΓöÇ Feature branches ΓöÇΓöÇΓåÆ dev ΓöÇΓöÇ (merge/rebase) ΓöÇΓöÇΓåÆ preview Γöé +Γöé (squad/{issue}-{slug}) Γò▓ Γöé Γöé +Γöé ΓööΓöÇΓöÇΓåÆ Release tagged ΓöÇΓöÇΓöÇΓåÆ main Γöé +Γöé Γöé Γöé +Γöé Γ£à ALL files allowed ≡ƒÜ½ .squad/ BLOCKED ≡ƒÜ½ BLOCKED Γöé +Γöé (dev branch = safe sandbox) team-docs/ BLOCKED (except Γöé +Γöé (except blog/) tagged Γöé +Γöé releases) Γöé +ΓööΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÿ ``` ### Branch Purposes | Branch | Purpose | Protection | Files Allowed | |--------|---------|------------|---------------| -| **`dev`** | Development & integration | None | ✅ Everything (including `.squad/`) | -| **`feature/squad/{issue}-{slug}`** | Feature work | None — merge to dev | ✅ Everything (including `.squad/`) | -| **`preview`** | Staging & release candidate | Guard checks for `.squad/`, `team-docs/` (except blog/) | ✅ Most files — see [Protected Files](#whats-protected) | -| **`main`** | Production & releases | Guard checks for `.squad/`, `team-docs/` (except blog/) | ✅ Most files — see [Protected Files](#whats-protected) | +| **`dev`** | Development & integration | None | Γ£à Everything (including `.squad/`) | +| **`feature/squad/{issue}-{slug}`** | Feature work | None ΓÇö merge to dev | Γ£à Everything (including `.squad/`) | +| **`preview`** | Staging & release candidate | Guard checks for `.squad/`, `team-docs/` (except blog/) | Γ£à Most files ΓÇö see [Protected Files](#whats-protected) | +| **`main`** | Production & releases | Guard checks for `.squad/`, `team-docs/` (except blog/) | Γ£à Most files ΓÇö see [Protected Files](#whats-protected) | ### Creating a Feature Branch @@ -84,7 +84,7 @@ git checkout -b squad/fix-silent-success-on-sync ### Merging to dev -When your work is ready, create a PR **targeting `dev`** (not `main` or `preview`). No guard checks apply to `dev` — it's a safe sandbox for any changes. +When your work is ready, create a PR **targeting `dev`** (not `main` or `preview`). No guard checks apply to `dev` ΓÇö it's a safe sandbox for any changes. ```bash git push origin squad/42-auth-refresh @@ -95,31 +95,31 @@ git push origin squad/42-auth-refresh ## What's Protected -### 🚫 CRITICAL: Files Blocked from `main` and `preview` +### ≡ƒÜ½ CRITICAL: Files Blocked from `main` and `preview` These files are **runtime team state** and live on `dev` and feature branches. They are committed to git and flow freely between `dev` and feature branches, but the **guard workflow** (`squad-main-guard.yml`) blocks them from reaching `main` or `preview`: | Path | Reason | Committed to `dev`? | Merged to `main`/`preview`? | |------|--------|---------------------|---------------------------| -| **`.squad/**`** | Agent charters, routing, decisions, history, casting registry | ✅ YES | ❌ NEVER — guard blocks | -| **`team-docs/**`** | Internal team documentation, sprint plans, notes | ✅ YES | ❌ NEVER — guard blocks | +| **`.squad/**`** | Agent charters, routing, decisions, history, casting registry | Γ£à YES | Γ¥î NEVER ΓÇö guard blocks | +| **`team-docs/**`** | Internal team documentation, sprint plans, notes | Γ£à YES | Γ¥î NEVER ΓÇö guard blocks | -**Why?** `.squad/` contains persistent agent knowledge, routing rules, and decision history. `team-docs/` contains internal proposals, sprint plans, and working notes. Both are internal infrastructure that belongs on development branches — not in production. The guard workflow is the enforcement mechanism, not `.gitignore`. `.squad/` is NOT in `.gitignore` — it's a normal part of the `dev` branch. The `.npmignore` file ensures both are excluded from the published npm package. Blog posts live in `docs/blog/` and flow freely to all branches. +**Why?** `.squad/` contains persistent agent knowledge, routing rules, and decision history. `team-docs/` contains internal proposals, sprint plans, and working notes. Both are internal infrastructure that belongs on development branches ΓÇö not in production. The guard workflow is the enforcement mechanism, not `.gitignore`. `.squad/` is NOT in `.gitignore` ΓÇö it's a normal part of the `dev` branch. The `.npmignore` file ensures both are excluded from the published npm package. Blog posts live in `docs/blog/` and flow freely to all branches. -### ✅ Files That Flow Freely +### Γ£à Files That Flow Freely -These files move between `dev` → `preview` → `main` with no restrictions: +These files move between `dev` ΓåÆ `preview` ΓåÆ `main` with no restrictions: -- `index.js` — CLI entry point -- `squad.agent.md` — Squad coordinator -- `templates/` — Agent templates -- `docs/` — Public documentation (including `docs/blog/`) -- `test/` — Test suite -- `.github/workflows/` — GitHub Actions workflows -- `package.json` — Dependencies -- `README.md`, `LICENSE` — Project metadata -- `CHANGELOG.md` — Release history -- `.gitignore`, `.gitattributes`, `.npmignore` — Git configuration +- `index.js` ΓÇö CLI entry point +- `squad.agent.md` ΓÇö Squad coordinator +- `templates/` ΓÇö Agent templates +- `docs/` ΓÇö Public documentation (including `docs/blog/`) +- `test/` ΓÇö Test suite +- `.github/workflows/` ΓÇö GitHub Actions workflows +- `package.json` ΓÇö Dependencies +- `README.md`, `LICENSE` ΓÇö Project metadata +- `CHANGELOG.md` ΓÇö Release history +- `.gitignore`, `.gitattributes`, `.npmignore` ΓÇö Git configuration --- @@ -147,7 +147,7 @@ Follow [commit message conventions](#commit-message-convention) (below). ### Step 3: Open PR Targeting `dev` On GitHub, create a PR with: -- **Base branch:** `dev` ← **Always target dev first** +- **Base branch:** `dev` ΓåÉ **Always target dev first** - **Title:** Follows conventional commits (e.g., `feat: add auth refresh`, `fix: silent success bug`) - **Description:** What changed, why, and any testing notes @@ -156,10 +156,10 @@ On GitHub, create a PR with: If you accidentally (or intentionally) target `preview` or `main`, the **guard workflow** (`squad-main-guard.yml`) runs: ```yaml -✅ If no forbidden files detected: +Γ£à If no forbidden files detected: PR checks pass, you can merge. -❌ If forbidden files detected (.squad/, team-docs/ except blog/): +Γ¥î If forbidden files detected (.squad/, team-docs/ except blog/): Workflow fails with actionable error message. You must remove the files before merging. ``` @@ -196,7 +196,7 @@ npm test node --test test/*.test.js ``` -Tests should pass before you open a PR. If a test fails, check if it's related to your changes. If you're fixing a known failing test as part of your work, that's fine — but don't introduce new failures. +Tests should pass before you open a PR. If a test fails, check if it's related to your changes. If you're fixing a known failing test as part of your work, that's fine ΓÇö but don't introduce new failures. --- @@ -214,13 +214,13 @@ Squad follows [Conventional Commits](https://www.conventionalcommits.org/): ### Types -- **`feat`** — New feature -- **`fix`** — Bug fix -- **`docs`** — Documentation changes (README, guides, etc.) -- **`ci`** — CI/CD changes (workflows, actions) -- **`chore`** — Maintenance, dependencies, build changes -- **`refactor`** — Code restructuring without behavior change -- **`test`** — Adding or fixing tests +- **`feat`** ΓÇö New feature +- **`fix`** ΓÇö Bug fix +- **`docs`** ΓÇö Documentation changes (README, guides, etc.) +- **`ci`** ΓÇö CI/CD changes (workflows, actions) +- **`chore`** ΓÇö Maintenance, dependencies, build changes +- **`refactor`** ΓÇö Code restructuring without behavior change +- **`test`** ΓÇö Adding or fixing tests ### Examples @@ -254,12 +254,12 @@ Squad doesn't use a linter. Keep it consistent with the existing codebase: ### Example ```javascript -// ✅ Good +// Γ£à Good const calculateContextUsage = (agentCount, decisionSize) => { return Math.min(agentCount * 45000, 200000 - decisionSize); }; -// ❌ Avoid +// Γ¥î Avoid const calc = (a, b) => Math.min(a * 45000, 200000 - b); // calc context ``` @@ -278,7 +278,7 @@ Squad uses a structured label system to organize work. When you open a PR or iss | **`go:*`** | Release target | `go:v0.4.1`, `go:v0.5.0` | | **`release:*`** | Release metadata | `release:major`, `release:minor`, `release:patch` | -You don't need to add these yourself — the Lead will triage and label issues. But knowing the taxonomy helps you understand what's happening. +You don't need to add these yourself ΓÇö the Lead will triage and label issues. But knowing the taxonomy helps you understand what's happening. --- @@ -286,42 +286,42 @@ You don't need to add these yourself — the Lead will triage and label issues. ``` squad/ -├── .squad/ ✅ Committed on dev & feature branches -│ ├── agents/ 🚫 Guard blocks from main/preview -│ │ ├── {name}/charter.md 🚫 Guard blocks from main/preview -│ │ └── {name}/history.md 🚫 Guard blocks from main/preview -│ ├── team.md 🚫 Guard blocks from main/preview -│ ├── routing.md 🚫 Guard blocks from main/preview -│ ├── decisions.md 🚫 Guard blocks from main/preview -│ └── ... -│ -├── team-docs/ 🚫 Guard blocks from main/preview (except blog/) -│ ├── sprint-plan.md 🚫 Guard blocks from main/preview -│ ├── roadmap.md 🚫 Guard blocks from main/preview -│ └── blog/ ✅ ALLOWED (public content) -│ └── 001-launch.md ✅ ALLOWED -│ -├── index.js ✅ Flows freely -├── squad.agent.md ✅ Flows freely -├── README.md ✅ Flows freely -├── CONTRIBUTING.md ✅ Flows freely (this file) -├── CHANGELOG.md ✅ Flows freely -├── package.json ✅ Flows freely -├── LICENSE ✅ Flows freely -│ -├── docs/ ✅ Flows freely -│ ├── community.md ✅ Flows freely -│ ├── features/ ✅ Flows freely -│ └── scenarios/ ✅ Flows freely -│ -├── templates/ ✅ Flows freely -├── test/ ✅ Flows freely -├── .github/workflows/ ✅ Flows freely -│ └── squad-main-guard.yml ✅ Flows freely -│ -├── .gitignore ✅ Flows freely -├── .gitattributes ✅ Flows freely -└── .npmignore ✅ Flows freely +Γö£ΓöÇΓöÇ .squad/ Γ£à Committed on dev & feature branches +Γöé Γö£ΓöÇΓöÇ agents/ ≡ƒÜ½ Guard blocks from main/preview +Γöé Γöé Γö£ΓöÇΓöÇ {name}/charter.md ≡ƒÜ½ Guard blocks from main/preview +Γöé Γöé ΓööΓöÇΓöÇ {name}/history.md ≡ƒÜ½ Guard blocks from main/preview +Γöé Γö£ΓöÇΓöÇ team.md ≡ƒÜ½ Guard blocks from main/preview +Γöé Γö£ΓöÇΓöÇ routing.md ≡ƒÜ½ Guard blocks from main/preview +Γöé Γö£ΓöÇΓöÇ decisions.md ≡ƒÜ½ Guard blocks from main/preview +Γöé ΓööΓöÇΓöÇ ... +Γöé +Γö£ΓöÇΓöÇ team-docs/ ≡ƒÜ½ Guard blocks from main/preview (except blog/) +Γöé Γö£ΓöÇΓöÇ sprint-plan.md ≡ƒÜ½ Guard blocks from main/preview +Γöé Γö£ΓöÇΓöÇ roadmap.md ≡ƒÜ½ Guard blocks from main/preview +Γöé ΓööΓöÇΓöÇ blog/ Γ£à ALLOWED (public content) +Γöé ΓööΓöÇΓöÇ 001-launch.md Γ£à ALLOWED +Γöé +Γö£ΓöÇΓöÇ index.js Γ£à Flows freely +Γö£ΓöÇΓöÇ squad.agent.md Γ£à Flows freely +Γö£ΓöÇΓöÇ README.md Γ£à Flows freely +Γö£ΓöÇΓöÇ CONTRIBUTING.md Γ£à Flows freely (this file) +Γö£ΓöÇΓöÇ CHANGELOG.md Γ£à Flows freely +Γö£ΓöÇΓöÇ package.json Γ£à Flows freely +Γö£ΓöÇΓöÇ LICENSE Γ£à Flows freely +Γöé +Γö£ΓöÇΓöÇ docs/ Γ£à Flows freely +Γöé Γö£ΓöÇΓöÇ community.md Γ£à Flows freely +Γöé Γö£ΓöÇΓöÇ features/ Γ£à Flows freely +Γöé ΓööΓöÇΓöÇ scenarios/ Γ£à Flows freely +Γöé +Γö£ΓöÇΓöÇ templates/ Γ£à Flows freely +Γö£ΓöÇΓöÇ test/ Γ£à Flows freely +Γö£ΓöÇΓöÇ .github/workflows/ Γ£à Flows freely +Γöé ΓööΓöÇΓöÇ squad-main-guard.yml Γ£à Flows freely +Γöé +Γö£ΓöÇΓöÇ .gitignore Γ£à Flows freely +Γö£ΓöÇΓöÇ .gitattributes Γ£à Flows freely +ΓööΓöÇΓöÇ .npmignore Γ£à Flows freely ``` --- @@ -332,14 +332,14 @@ When you open a PR to `main` or `preview`, the workflow `.github/workflows/squad 1. **Fetches all files changed in your PR** (paginated for large PRs) 2. **Checks each file against forbidden path rules:** - - If filename starts with `.squad/` → BLOCKED - - If filename starts with `team-docs/` → BLOCKED - - Otherwise → ALLOWED + - If filename starts with `.squad/` ΓåÆ BLOCKED + - If filename starts with `team-docs/` ΓåÆ BLOCKED + - Otherwise ΓåÆ ALLOWED 3. **Reports results:** - - ✅ **Pass:** "No forbidden paths found" — you're good to merge - - ❌ **Fail:** Lists forbidden files and shows `git rm --cached` fix + - Γ£à **Pass:** "No forbidden paths found" ΓÇö you're good to merge + - Γ¥î **Fail:** Lists forbidden files and shows `git rm --cached` fix -The guard is **not a suggestion** — it's a hard stop. This is the primary enforcement mechanism that keeps `.squad/` and internal `team-docs/` off `main` and `preview`. But it's easy to fix if it blocks you (see [Fixing a Blocked PR](#fixing-a-blocked-pr)). +The guard is **not a suggestion** ΓÇö it's a hard stop. This is the primary enforcement mechanism that keeps `.squad/` and internal `team-docs/` off `main` and `preview`. But it's easy to fix if it blocks you (see [Fixing a Blocked PR](#fixing-a-blocked-pr)). --- @@ -347,7 +347,7 @@ The guard is **not a suggestion** — it's a hard stop. This is the primary enfo ### Q: I accidentally committed `.squad/` to my feature branch. Do I have to delete it? -**A:** Nope — `.squad/` files are **supposed** to be committed on `dev` and feature branches! They're part of the normal development workflow. The guard workflow (`squad-main-guard.yml`) prevents them from reaching `main` or `preview`. Just don't PR them to those branches. +**A:** Nope ΓÇö `.squad/` files are **supposed** to be committed on `dev` and feature branches! They're part of the normal development workflow. The guard workflow (`squad-main-guard.yml`) prevents them from reaching `main` or `preview`. Just don't PR them to those branches. If you're creating a PR to `main` or `preview` and the guard blocks it, remove the files from that PR only: @@ -359,15 +359,15 @@ git push ### Q: Can I PR to `main` directly? -**A:** Technically yes, but don't. Always target `dev` first. Releases flow dev → preview → main via controlled releases, not ad-hoc PRs. This keeps `main` a stable mirror of what's deployed. +**A:** Technically yes, but don't. Always target `dev` first. Releases flow dev ΓåÆ preview ΓåÆ main via controlled releases, not ad-hoc PRs. This keeps `main` a stable mirror of what's deployed. ### Q: The guard blocked my PR. What now? -**A:** Your PR targets `main` or `preview` and contains `.squad/` or `team-docs/` files. These files live on `dev` and feature branches but must not reach production. Follow [Fixing a Blocked PR](#fixing-a-blocked-pr) — it's three `git rm --cached` commands and a push. The workflow will re-run and pass. +**A:** Your PR targets `main` or `preview` and contains `.squad/` or `team-docs/` files. These files live on `dev` and feature branches but must not reach production. Follow [Fixing a Blocked PR](#fixing-a-blocked-pr) ΓÇö it's three `git rm --cached` commands and a push. The workflow will re-run and pass. -### Q: I want to commit `team-docs/sprint-plan.md` — can I do that? +### Q: I want to commit `team-docs/sprint-plan.md` ΓÇö can I do that? -**A:** Not to `main` or `preview` — it's internal. Commit it to `dev` and feature branches, and the guard will block it if you accidentally PR it to `main`. If it's public content (blog, guides, etc.), put it in `docs/blog/` and it flows freely. +**A:** Not to `main` or `preview` ΓÇö it's internal. Commit it to `dev` and feature branches, and the guard will block it if you accidentally PR it to `main`. If it's public content (blog, guides, etc.), put it in `docs/blog/` and it flows freely. ### Q: What if I disagree with the branch protection? @@ -377,7 +377,7 @@ git push ## Insider Program -Interested in cutting-edge builds? See [CONTRIBUTORS.md](CONTRIBUTORS.md#insider-program) for the Insider Program — early access to development builds and a chance to shape Squad's future. +Interested in cutting-edge builds? See [CONTRIBUTORS.md](CONTRIBUTORS.md#insider-program) for the Insider Program ΓÇö early access to development builds and a chance to shape Squad's future. --- @@ -387,14 +387,14 @@ Interested in cutting-edge builds? See [CONTRIBUTORS.md](CONTRIBUTORS.md#insider - **Questions & Discussions:** [GitHub Discussions](https://github.com/bradygaster/squad/discussions) - **Security Issues:** Report privately via [GitHub Security Advisory](https://github.com/bradygaster/squad/security/advisories) -Welcome aboard. Make Squad better. 🚀 +Welcome aboard. Make Squad better. ≡ƒÜÇ --- ## Summary: What You Need to Know 1. **Clone from `dev`, create `squad/{issue}-{slug}` branch, PR back to `dev`** -2. **`.squad/` files are committed on `dev` and feature branches — the guard workflow blocks them from `main`/`preview`** +2. **`.squad/` files are committed on `dev` and feature branches ΓÇö the guard workflow blocks them from `main`/`preview`** 3. **Run `npm test` before pushing** 4. **Follow conventional commits (feat:, fix:, docs:, etc.)** 5. **If the guard blocks your PR to `main`/`preview`, run `git rm --cached` and push again** diff --git a/docs/blog/001-wave-0-the-team-that-built-itself.md b/docs/blog/001-wave-0-the-team-that-built-itself.md index 3e917a9eb..f3093394e 100644 --- a/docs/blog/001-wave-0-the-team-that-built-itself.md +++ b/docs/blog/001-wave-0-the-team-that-built-itself.md @@ -5,34 +5,34 @@ author: "McManus (DevRel)" wave: 0 tags: [squad, wave-0, team-formation, self-repair, silent-success-bug, origin-story] status: published -hero: "We asked Squad to build itself a team. It wrote 16 proposals, discovered its own worst bug, and fixed it — all in one session." +hero: "We asked Squad to build itself a team. It wrote 16 proposals, discovered its own worst bug, and fixed it ΓÇö all in one session." --- # Wave 0: The Team That Built Itself -> _We asked Squad to build itself a team. It wrote 16 proposals, discovered its own worst bug, and fixed it — all in one session._ +> _We asked Squad to build itself a team. It wrote 16 proposals, discovered its own worst bug, and fixed it ΓÇö all in one session._ ## What Shipped -- **The team itself** — Five specialists cast from The Usual Suspects: Keaton (Lead), Verbal (Prompt Engineer), McManus (DevRel), Fenster (Core Dev), Hockney (Tester). Not role labels — persistent identities with memory, voice, and expertise that compound across sessions. _(Built by Copilot)_ -- **16 proposals (~350KB)** — Architecture, messaging, demo scripts, video strategy, portable squads, skills system, tiered response modes, agent experience evolution, and more. All written, cross-referenced, and reviewed in a single session. _(Built by the full squad)_ -- **The silent success bug discovery and mitigation** — ~40% of agents were completing all their work but returning "no response" to the coordinator. The agents that did the most work were the ones that failed. Three zero-risk mitigations shipped same-session. _(Discovered by Kujan, mitigated in squad.agent.md)_ -- **12 tests passing** — Squad's first test suite, built with `node:test` and `node:assert`. Zero external dependencies. Init, idempotency, recursive copy — the foundation. _(Built by Hockney)_ -- **Upgrade subcommand** — `npx @bradygaster/create-squad upgrade` overwrites Squad-owned files, never touches your team state. The delivery mechanism for bug fixes to existing users. _(Built by Fenster)_ -- **Demo script ACT 7 restored** — The silent success bug ate a 60-second section of the demo script. The KEY THEMES reference table referenced content that didn't exist. Found it, reconstructed it, shipped it. _(Restored by McManus)_ -- **Master Sprint Plan (Proposal 019)** — 21 work items, 3 waves, parallel content track, 44-59 hours estimated. One document the entire team executes from. No ambiguity, no redundancy. _(Authored by Keaton)_ +- **The team itself** ΓÇö Five specialists cast from The Usual Suspects: Keaton (Lead), Verbal (Prompt Engineer), McManus (DevRel), Fenster (Core Dev), Hockney (Tester). Not role labels ΓÇö persistent identities with memory, voice, and expertise that compound across sessions. _(Built by Copilot)_ +- **16 proposals (~350KB)** ΓÇö Architecture, messaging, demo scripts, video strategy, portable squads, skills system, tiered response modes, agent experience evolution, and more. All written, cross-referenced, and reviewed in a single session. _(Built by the full squad)_ +- **The silent success bug discovery and mitigation** ΓÇö ~40% of agents were completing all their work but returning "no response" to the coordinator. The agents that did the most work were the ones that failed. Three zero-risk mitigations shipped same-session. _(Discovered by Kujan, mitigated in squad.agent.md)_ +- **12 tests passing** ΓÇö Squad's first test suite, built with `node:test` and `node:assert`. Zero external dependencies. Init, idempotency, recursive copy ΓÇö the foundation. _(Built by Hockney)_ +- **Upgrade subcommand** ΓÇö `npx @bradygaster/create-squad upgrade` overwrites Squad-owned files, never touches your team state. The delivery mechanism for bug fixes to existing users. _(Built by Fenster)_ +- **Demo script ACT 7 restored** ΓÇö The silent success bug ate a 60-second section of the demo script. The KEY THEMES reference table referenced content that didn't exist. Found it, reconstructed it, shipped it. _(Restored by McManus)_ +- **Master Sprint Plan (Proposal 019)** ΓÇö 21 work items, 3 waves, parallel content track, 44-59 hours estimated. One document the entire team executes from. No ambiguity, no redundancy. _(Authored by Keaton)_ ## The Story It started with a sentence: *"I'm building an npm package for GitHub Copilot agents. Set up the team."* -Brady typed that into Copilot, selected Squad, and hit enter. What happened next wasn't planned. The coordinator analyzed the codebase — `index.js`, `package.json`, the templates, the `.github/agents/` directory — and proposed a team. Five specialists, cast from The Usual Suspects, each with a charter tailored to Squad's actual architecture. +Brady typed that into Copilot, selected Squad, and hit enter. What happened next wasn't planned. The coordinator analyzed the codebase ΓÇö `index.js`, `package.json`, the templates, the `.github/agents/` directory ΓÇö and proposed a team. Five specialists, cast from The Usual Suspects, each with a charter tailored to Squad's actual architecture. Then they started working. In parallel. Keaton set priorities. Verbal designed the prompt engineering strategy. McManus audited the README and found six gaps. Fenster dug into `index.js` and proposed error handling. Hockney pointed out there were zero tests and wrote twelve. Each agent read the shared `decisions.md`, wrote their proposals, and cross-referenced each other's work. Sixteen proposals in one session. ~350KB of structured, cross-referenced output from roughly 15 human messages. -And then the bug. Kujan was investigating platform behavior when the data hit: approximately 40% of agent spawns were completing all their assigned work — writing files, updating histories, logging decisions — but returning empty responses to the coordinator. The coordinator logged "no response" and moved on. The work was done. The coordinator didn't know. +And then the bug. Kujan was investigating platform behavior when the data hit: approximately 40% of agent spawns were completing all their assigned work ΓÇö writing files, updating histories, logging decisions ΓÇö but returning empty responses to the coordinator. The coordinator logged "no response" and moved on. The work was done. The coordinator didn't know. -Here's the twist that makes the story: **success caused the failure.** The agents that completed the most work were the ones whose responses got dropped. Doing the right thing — finishing every task, writing history, updating decisions — triggered the bug. The silent success bug wasn't a failure of the agents. It was proof that they worked. +Here's the twist that makes the story: **success caused the failure.** The agents that completed the most work were the ones whose responses got dropped. Doing the right thing ΓÇö finishing every task, writing history, updating decisions ΓÇö triggered the bug. The silent success bug wasn't a failure of the agents. It was proof that they worked. The team self-diagnosed. Kujan identified the pattern. Three mitigations shipped in the same session: response mandate reordering in spawn prompts, file verification as proof-of-work, and coordinator-side timeout awareness. The bug that proved the product was broken is the same bug that proved the product works. @@ -52,9 +52,9 @@ The team self-diagnosed. Kujan identified the pattern. Three mitigations shipped ## What We Learned -- **The self-repair loop is the product.** Squad didn't just find its own bug — it diagnosed, mitigated, and documented it in the same session it was discovered. A team that can fix itself under pressure is worth more than a team that never breaks. -- **Proposals beat code for alignment.** Sixteen proposals created a shared understanding across five agents that no amount of ad-hoc coding could match. The proposal-first workflow isn't overhead — it's the mechanism that makes parallel work possible. -- **Reference tables are checksums.** The demo script's KEY THEMES table referenced ACT 7 three times — but ACT 7 didn't exist. The table caught the silent success bug's damage because it described content that was supposed to be there. Self-documenting formats catch silent failures. +- **The self-repair loop is the product.** Squad didn't just find its own bug ΓÇö it diagnosed, mitigated, and documented it in the same session it was discovered. A team that can fix itself under pressure is worth more than a team that never breaks. +- **Proposals beat code for alignment.** Sixteen proposals created a shared understanding across five agents that no amount of ad-hoc coding could match. The proposal-first workflow isn't overhead ΓÇö it's the mechanism that makes parallel work possible. +- **Reference tables are checksums.** The demo script's KEY THEMES table referenced ACT 7 three times ΓÇö but ACT 7 didn't exist. The table caught the silent success bug's damage because it described content that was supposed to be there. Self-documenting formats catch silent failures. ## What's Next @@ -62,4 +62,4 @@ Wave 1 is all about trust. Error handling in `index.js`, test expansion to 20+, --- -_This post was written by McManus, the DevRel on Squad's own team. Squad is an open source project by [@bradygaster](https://github.com/bradygaster). [Try it →](https://github.com/bradygaster/squad)_ +_This post was written by McManus, the DevRel on Squad's own team. Squad is an open source project by [@bradygaster](https://github.com/bradygaster). [Try it ΓåÆ](https://github.com/bradygaster/squad)_ diff --git a/docs/build.js b/docs/build.js index ebe1f8769..f7c0b930b 100644 --- a/docs/build.js +++ b/docs/build.js @@ -232,7 +232,7 @@ function generateLlmsTxt(files) { function generateLlmsFullTxt(files) { var parts = []; - parts.push('# Squad — Complete Documentation'); + parts.push('# Squad ΓÇö Complete Documentation'); parts.push(''); var included = files.slice(); diff --git a/docs/features/ralph.md b/docs/features/ralph.md index 77a21204a..9a161c2f0 100644 --- a/docs/features/ralph.md +++ b/docs/features/ralph.md @@ -71,9 +71,9 @@ When you're in a Copilot session, Ralph self-chains the coordinator's work loop: 2. Ralph checks GitHub for more: untriaged issues, assigned-but-unstarted items, draft PRs, failing CI 3. Work found → triage, assign, spawn agents 4. Results collected → Ralph checks again **immediately** — no pause, no asking permission -5. Board clear → Ralph enters **idle-watch** — polls every 10 minutes (configurable) for new work +5. Board clear → Ralph idles (use `npx github:bradygaster/squad watch` for persistent polling) -**Ralph never stops on his own while work remains.** He keeps cycling through the backlog until every issue is closed, every PR is merged, and CI is green. When the board clears, Ralph doesn't fully stop — he enters idle-watch mode and periodically checks for new work. The only things that fully stop Ralph: you say "idle"/"stop", or the session ends. +**Ralph never stops on his own while work remains.** He keeps cycling through the backlog until every issue is closed, every PR is merged, and CI is green. When the board clears, Ralph idles — run `npx github:bradygaster/squad watch` in a separate terminal for persistent polling, or use the cloud heartbeat for fully unattended monitoring. The only things that stop Ralph's active loop: the board is clear, you say "idle"/"stop", or the session ends. ### Between Sessions (GitHub Actions Heartbeat) @@ -93,8 +93,7 @@ This creates a fully autonomous loop for `@copilot` — heartbeat triages → as | "Ralph, go" / "Ralph, start monitoring" | Activates the work-check loop | | "Keep working" / "Work until done" | Activates Ralph | | "Ralph, status" / "What's on the board?" | Runs one check cycle, reports results | -| "Ralph, check every N minutes" | Sets the idle-watch polling interval (e.g., "Ralph, check every 30 minutes") | -| "Ralph, idle" / "Take a break" | Fully stops the loop and idle-watch polling | +| "Ralph, idle" / "Take a break" | Stops the loop | | "Ralph, scope: just issues" | Monitors only issues, skips PRs/CI | ## What Ralph Monitors @@ -119,43 +118,31 @@ Ralph doesn't run silently forever. Every 3-5 rounds, Ralph reports and **keeps Continuing... (say "Ralph, idle" to stop) ``` -Ralph does **not** ask permission to continue — he keeps working. The only things that fully stop Ralph: you say "idle"/"stop", or the session ends. A clear board puts Ralph into idle-watch mode, not full stop. +Ralph does **not** ask permission to continue — he keeps working. The only things that stop Ralph: the board is clear, you say "idle"/"stop", or the session ends. -## Idle-Watch Mode +## Watch Mode (`npx github:bradygaster/squad watch`) -When the board clears, Ralph doesn't fully stop. He enters **idle-watch** — a polling mode that automatically re-checks for new work on a timer. +Ralph's in-session loop processes work while it exists, then idles. For **persistent polling** when you're away from the keyboard, run the `squad watch` command in a separate terminal: +```bash +npx github:bradygaster/squad watch # polls every 10 minutes (default) +npx github:bradygaster/squad watch --interval 5 # polls every 5 minutes +npx github:bradygaster/squad watch --interval 30 # polls every 30 minutes ``` -📋 Board is clear. Ralph is watching — next check in 10 minutes. - (say "Ralph, idle" to fully stop) -``` - -### How it works - -1. Board clears → Ralph enters idle-watch -2. After {poll_interval} minutes (default: 10), Ralph re-scans GitHub -3. New work found → resumes the active work loop automatically -4. Still no work → waits another {poll_interval} minutes and checks again -5. Repeats until you say "Ralph, idle" or the session ends - -### Configuring the interval -You can adjust the polling interval at any time: +This runs as a standalone local process (not inside Copilot) that: +- Checks GitHub every N minutes for untriaged squad work +- Auto-triages issues based on team roles and keywords +- Assigns @copilot to `squad:copilot` issues (if auto-assign is enabled) +- Runs until Ctrl+C -| What you say | Interval | -|---|---| -| "Ralph, check every 5 minutes" | 5 min | -| "Ralph, check every 15 minutes" | 15 min | -| "Ralph, poll every 30 minutes" | 30 min | - -The default is **10 minutes**. The interval only affects idle-watch — when actively processing work, Ralph scans immediately after each batch. +### Three layers of Ralph -### Idle-watch vs. full idle - -| Mode | Behavior | How to enter | -|---|---|---| -| **Idle-watch** | Polls for new work every N minutes | Automatic when board clears | -| **Full idle** | Completely stopped, no polling | Say "Ralph, idle" or "stop" | +| Layer | When | How | +|-------|------|-----| +| **In-session** | You're at the keyboard | "Ralph, go" — active loop while work exists | +| **Local watchdog** | You're away but machine is on | `npx github:bradygaster/squad watch --interval 10` | +| **Cloud heartbeat** | Fully unattended | `squad-heartbeat.yml` GitHub Actions cron | ## Ralph's Board View @@ -194,7 +181,7 @@ on: ## Notes -- Ralph is session-scoped — his state (active/idle/watching, round count, poll interval, stats) resets each session +- Ralph is session-scoped — his state (active/idle, round count, stats) resets each session - Ralph appears on the roster like Scribe: `| Ralph | Work Monitor | — | 🔄 Monitor |` - Ralph is exempt from universe casting — always "Ralph" - The heartbeat workflow is the between-session complement to in-session Ralph @@ -214,10 +201,10 @@ Ralph, status Runs a single check cycle and shows the current board state without activating the work loop. ``` -Ralph, check every 5 minutes +npx github:bradygaster/squad watch --interval 5 ``` -Changes the idle-watch polling interval from the default 10 minutes to 5 minutes. +Starts persistent local polling — checks GitHub every 5 minutes for new squad work and triages automatically. ``` Ralph, scope: just issues diff --git a/index-ours.js b/index-ours.js new file mode 100644 index 000000000..293c58cba --- /dev/null +++ b/index-ours.js @@ -0,0 +1,1069 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const GREEN = '\x1b[32m'; +const RED = '\x1b[31m'; +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; + +function fatal(msg) { + console.error(`${RED}Γ£ù${RESET} ${msg}`); + process.exit(1); +} + +process.on('uncaughtException', (err) => { + fatal(`Unexpected error: ${err.message}`); +}); + +const root = __dirname; +const dest = process.cwd(); +const pkg = require(path.join(root, 'package.json')); +const cmd = process.argv[2]; + +// --version / --help +if (cmd === '--version' || cmd === '-v') { + console.log(pkg.version); + process.exit(0); +} + +if (cmd === '--help' || cmd === '-h' || cmd === 'help') { + console.log(`\n${BOLD}squad${RESET} v${pkg.version} ΓÇö Add an AI agent team to any project\n`); + console.log(`Usage: npx github:bradygaster/squad [command]\n`); + console.log(`Commands:`); + console.log(` ${BOLD}(default)${RESET} Initialize Squad (skip files that already exist)`); + console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); + console.log(` Overwrites: squad.agent.md, .ai-team-templates/`); + console.log(` Never touches: .ai-team/ (your team state)`); + console.log(` ${BOLD}copilot${RESET} Add/remove the Copilot coding agent (@copilot)`); + console.log(` Usage: copilot [--off] [--auto-assign]`); + console.log(` ${BOLD}watch${RESET} Run Ralph's work monitor as a local polling process`); + console.log(` Usage: watch [--interval ]`); + console.log(` Default: checks every 10 minutes (Ctrl+C to stop)`); + console.log(` ${BOLD}plugin${RESET} Manage plugin marketplaces`); + console.log(` Usage: plugin marketplace add|remove|list|browse`); + console.log(` ${BOLD}export${RESET} Export squad to a portable JSON snapshot`); + console.log(` Default: squad-export.json (use --out to override)`); + console.log(` ${BOLD}import${RESET} Import squad from an export file`); + console.log(` Usage: import [--force]`); + console.log(` ${BOLD}help${RESET} Show this help message`); + console.log(`\nFlags:`); + console.log(` ${BOLD}--version, -v${RESET} Print version`); + console.log(` ${BOLD}--help, -h${RESET} Show help\n`); + process.exit(0); +} + +function copyRecursive(src, target) { + try { + if (fs.statSync(src).isDirectory()) { + fs.mkdirSync(target, { recursive: true }); + for (const entry of fs.readdirSync(src)) { + copyRecursive(path.join(src, entry), path.join(target, entry)); + } + } else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(src, target); + } + } catch (err) { + fatal(`Failed to copy ${path.relative(root, src)}: ${err.message}`); + } +} + +// --- Watch subcommand (Ralph local watchdog) --- +if (cmd === 'watch') { + const { execSync } = require('child_process'); + + const teamMd = path.join(dest, '.ai-team', 'team.md'); + if (!fs.existsSync(teamMd)) { + fatal('No squad found ΓÇö run init first.'); + } + + // Verify gh CLI is available + try { + execSync('gh --version', { stdio: 'pipe' }); + } catch { + fatal('gh CLI not found ΓÇö install from https://cli.github.com'); + } + + // Parse --interval flag (default: 10 minutes) + const intervalIdx = process.argv.indexOf('--interval'); + const intervalMin = (intervalIdx !== -1 && process.argv[intervalIdx + 1]) + ? parseInt(process.argv[intervalIdx + 1], 10) + : 10; + + if (isNaN(intervalMin) || intervalMin < 1) { + fatal('--interval must be a positive number of minutes'); + } + + const content = fs.readFileSync(teamMd, 'utf8'); + + // Parse members from roster + function parseMembers(text) { + const lines = text.split('\n'); + const members = []; + let inMembersTable = false; + for (const line of lines) { + if (line.startsWith('## Members')) { inMembersTable = true; continue; } + if (inMembersTable && line.startsWith('## ')) break; + if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { + const cells = line.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 2 && !['Scribe', 'Ralph'].includes(cells[0])) { + members.push({ name: cells[0], role: cells[1], label: `squad:${cells[0].toLowerCase()}` }); + } + } + } + return members; + } + + const members = parseMembers(content); + if (members.length === 0) { + fatal('No squad members found in team.md'); + } + + const hasCopilot = content.includes('≡ƒñû Coding Agent') || content.includes('@copilot'); + const autoAssign = content.includes(''); + + console.log(`\n${BOLD}≡ƒöä Ralph ΓÇö Watch Mode${RESET}`); + console.log(`${DIM}Polling every ${intervalMin} minute(s) for squad work. Ctrl+C to stop.${RESET}\n`); + + function runCheck() { + const timestamp = new Date().toLocaleTimeString(); + try { + // Fetch open issues with squad label + const issuesJson = execSync( + 'gh issue list --label "squad" --state open --json number,title,labels,assignees --limit 20', + { stdio: 'pipe', encoding: 'utf8' } + ); + const issues = JSON.parse(issuesJson || '[]'); + + const memberLabels = members.map(m => m.label); + const untriaged = issues.filter(issue => { + const issueLabels = issue.labels.map(l => l.name); + return !memberLabels.some(ml => issueLabels.includes(ml)); + }); + + // Find unassigned squad:copilot issues + let unassignedCopilot = []; + if (hasCopilot && autoAssign) { + try { + const copilotJson = execSync( + 'gh issue list --label "squad:copilot" --state open --json number,title,assignees --limit 10', + { stdio: 'pipe', encoding: 'utf8' } + ); + const copilotIssues = JSON.parse(copilotJson || '[]'); + unassignedCopilot = copilotIssues.filter(i => !i.assignees || i.assignees.length === 0); + } catch { /* label may not exist */ } + } + + if (untriaged.length === 0 && unassignedCopilot.length === 0) { + console.log(`${DIM}[${timestamp}]${RESET} ≡ƒôï Board is clear ΓÇö no pending work`); + return; + } + + // Triage untriaged issues + for (const issue of untriaged) { + const issueText = `${issue.title}`.toLowerCase(); + let assignedMember = null; + let reason = ''; + + for (const member of members) { + const role = member.role.toLowerCase(); + if ((role.includes('frontend') || role.includes('ui')) && + (issueText.includes('ui') || issueText.includes('frontend') || issueText.includes('css'))) { + assignedMember = member; reason = 'frontend/UI domain'; break; + } + if ((role.includes('backend') || role.includes('api') || role.includes('server')) && + (issueText.includes('api') || issueText.includes('backend') || issueText.includes('database'))) { + assignedMember = member; reason = 'backend/API domain'; break; + } + if ((role.includes('test') || role.includes('qa')) && + (issueText.includes('test') || issueText.includes('bug') || issueText.includes('fix'))) { + assignedMember = member; reason = 'testing/QA domain'; break; + } + } + + if (!assignedMember) { + const lead = members.find(m => + m.role.toLowerCase().includes('lead') || m.role.toLowerCase().includes('architect') + ); + if (lead) { assignedMember = lead; reason = 'no domain match ΓÇö routed to Lead'; } + } + + if (assignedMember) { + try { + execSync(`gh issue edit ${issue.number} --add-label "${assignedMember.label}"`, { stdio: 'pipe' }); + console.log(`${GREEN}Γ£ô${RESET} [${timestamp}] Triaged #${issue.number} "${issue.title}" ΓåÆ ${assignedMember.name} (${reason})`); + } catch (e) { + console.error(`${RED}Γ£ù${RESET} [${timestamp}] Failed to label #${issue.number}: ${e.message}`); + } + } + } + + // Assign @copilot to unassigned copilot issues + for (const issue of unassignedCopilot) { + try { + execSync( + `gh issue edit ${issue.number} --add-assignee copilot-swe-agent`, + { stdio: 'pipe' } + ); + console.log(`${GREEN}Γ£ô${RESET} [${timestamp}] Assigned @copilot to #${issue.number} "${issue.title}"`); + } catch (e) { + console.error(`${RED}Γ£ù${RESET} [${timestamp}] Failed to assign @copilot to #${issue.number}: ${e.message}`); + } + } + + } catch (e) { + console.error(`${RED}Γ£ù${RESET} [${timestamp}] Check failed: ${e.message}`); + } + } + + // Run immediately, then on interval + runCheck(); + setInterval(runCheck, intervalMin * 60 * 1000); + + // Handle Ctrl+C gracefully + process.on('SIGINT', () => { + console.log(`\n${DIM}≡ƒöä Ralph ΓÇö Watch stopped${RESET}`); + process.exit(0); + }); + + // Prevent fall-through to init/upgrade logic + return; +} + +// --- Copilot subcommand --- +if (cmd === 'copilot') { + const teamMd = path.join(dest, '.ai-team', 'team.md'); + if (!fs.existsSync(teamMd)) { + fatal('No squad found ΓÇö run init first, then add the copilot agent.'); + } + + const isOff = process.argv.includes('--off'); + const autoAssign = process.argv.includes('--auto-assign'); + let content = fs.readFileSync(teamMd, 'utf8'); + const hasCopilot = content.includes('≡ƒñû Coding Agent'); + + if (isOff) { + if (!hasCopilot) { + console.log(`${DIM}Copilot coding agent is not on the team ΓÇö nothing to remove${RESET}`); + process.exit(0); + } + // Remove the Coding Agent section + content = content.replace(/\n## Coding Agent\n[\s\S]*?(?=\n## |\n*$)/, ''); + fs.writeFileSync(teamMd, content); + console.log(`${GREEN}Γ£ô${RESET} Removed @copilot from the team roster`); + + // Remove copilot-instructions.md + const instructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); + if (fs.existsSync(instructionsDest)) { + fs.unlinkSync(instructionsDest); + console.log(`${GREEN}Γ£ô${RESET} Removed .github/copilot-instructions.md`); + } + process.exit(0); + } + + // Adding copilot + if (hasCopilot) { + // Update auto-assign if requested + if (autoAssign) { + content = content.replace('', ''); + fs.writeFileSync(teamMd, content); + console.log(`${GREEN}Γ£ô${RESET} Enabled @copilot auto-assign`); + } else { + console.log(`${DIM}@copilot is already on the team${RESET}`); + } + process.exit(0); + } + + // Add Coding Agent section before Project Context + const autoAssignValue = autoAssign ? 'true' : 'false'; + const copilotSection = ` +## Coding Agent + + + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| @copilot | Coding Agent | ΓÇö | ≡ƒñû Coding Agent | + +### Capabilities + +**≡ƒƒó Good fit ΓÇö auto-route when enabled:** +- Bug fixes with clear reproduction steps +- Test coverage (adding missing tests, fixing flaky tests) +- Lint/format fixes and code style cleanup +- Dependency updates and version bumps +- Small isolated features with clear specs +- Boilerplate/scaffolding generation +- Documentation fixes and README updates + +**≡ƒƒí Needs review ΓÇö route to @copilot but flag for squad member PR review:** +- Medium features with clear specs and acceptance criteria +- Refactoring with existing test coverage +- API endpoint additions following established patterns +- Migration scripts with well-defined schemas + +**≡ƒö┤ Not suitable ΓÇö route to squad member instead:** +- Architecture decisions and system design +- Multi-system integration requiring coordination +- Ambiguous requirements needing clarification +- Security-critical changes (auth, encryption, access control) +- Performance-critical paths requiring benchmarking +- Changes requiring cross-team discussion + +`; + + // Insert before "## Project Context" if it exists, otherwise append + if (content.includes('## Project Context')) { + content = content.replace('## Project Context', copilotSection + '## Project Context'); + } else { + content = content.trimEnd() + '\n' + copilotSection; + } + + fs.writeFileSync(teamMd, content); + console.log(`${GREEN}Γ£ô${RESET} Added @copilot (Coding Agent) to team roster`); + if (autoAssign) { + console.log(`${GREEN}Γ£ô${RESET} Auto-assign enabled ΓÇö squad-labeled issues will be assigned to @copilot`); + } + + // Copy copilot-instructions.md + const instructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); + const instructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); + if (fs.existsSync(instructionsSrc)) { + fs.mkdirSync(path.dirname(instructionsDest), { recursive: true }); + fs.copyFileSync(instructionsSrc, instructionsDest); + console.log(`${GREEN}Γ£ô${RESET} .github/copilot-instructions.md`); + } + + console.log(); + console.log(`${BOLD}@copilot is on the team.${RESET}`); + console.log(`The coding agent will pick up issues matching its capability profile.`); + if (!autoAssign) { + console.log(`Run with ${BOLD}--auto-assign${RESET} to auto-assign @copilot on squad-labeled issues.`); + } + console.log(); + console.log(`${BOLD}Required:${RESET} Add a classic PAT (repo scope) as a repo secret for auto-assignment:`); + console.log(` 1. Create token: ${DIM}https://github.com/settings/tokens/new${RESET}`); + console.log(` 2. Set secret: ${DIM}gh secret set COPILOT_ASSIGN_TOKEN${RESET}`); + console.log(); + process.exit(0); +} + +// --- Plugin marketplace subcommand --- +if (cmd === 'plugin') { + const subCmd = process.argv[3]; + const action = process.argv[4]; + + if (subCmd !== 'marketplace' || !action) { + fatal('Usage: squad plugin marketplace add|remove|list|browse'); + } + + const pluginsDir = path.join(dest, '.ai-team', 'plugins'); + const marketplacesFile = path.join(pluginsDir, 'marketplaces.json'); + + function readMarketplaces() { + if (!fs.existsSync(marketplacesFile)) return { marketplaces: [] }; + try { + return JSON.parse(fs.readFileSync(marketplacesFile, 'utf8')); + } catch { + return { marketplaces: [] }; + } + } + + function writeMarketplaces(data) { + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync(marketplacesFile, JSON.stringify(data, null, 2) + '\n'); + } + + if (action === 'add') { + const source = process.argv[5]; + if (!source || !source.includes('/')) { + fatal('Usage: squad plugin marketplace add '); + } + const data = readMarketplaces(); + const name = source.split('/').pop(); + if (data.marketplaces.some(m => m.source === source)) { + console.log(`${DIM}${source} is already registered${RESET}`); + process.exit(0); + } + data.marketplaces.push({ + name, + source, + added_at: new Date().toISOString() + }); + writeMarketplaces(data); + console.log(`${GREEN}Γ£ô${RESET} Registered marketplace: ${BOLD}${name}${RESET} (${source})`); + process.exit(0); + } + + if (action === 'remove') { + const name = process.argv[5]; + if (!name) { + fatal('Usage: squad plugin marketplace remove '); + } + const data = readMarketplaces(); + const before = data.marketplaces.length; + data.marketplaces = data.marketplaces.filter(m => m.name !== name); + if (data.marketplaces.length === before) { + fatal(`Marketplace "${name}" not found`); + } + writeMarketplaces(data); + console.log(`${GREEN}Γ£ô${RESET} Removed marketplace: ${BOLD}${name}${RESET}`); + process.exit(0); + } + + if (action === 'list') { + const data = readMarketplaces(); + if (data.marketplaces.length === 0) { + console.log(`${DIM}No marketplaces registered${RESET}`); + console.log(`\nAdd one with: ${BOLD}squad plugin marketplace add ${RESET}`); + process.exit(0); + } + console.log(`\n${BOLD}Registered marketplaces:${RESET}\n`); + for (const m of data.marketplaces) { + const date = m.added_at ? ` ${DIM}(added ${m.added_at.split('T')[0]})${RESET}` : ''; + console.log(` ${BOLD}${m.name}${RESET} ΓåÆ ${m.source}${date}`); + } + console.log(); + process.exit(0); + } + + if (action === 'browse') { + const name = process.argv[5]; + if (!name) { + fatal('Usage: squad plugin marketplace browse '); + } + const data = readMarketplaces(); + const marketplace = data.marketplaces.find(m => m.name === name); + if (!marketplace) { + fatal(`Marketplace "${name}" not found. Run "squad plugin marketplace list" to see registered marketplaces.`); + } + + // Browse the marketplace repo for plugins using gh CLI + const { execSync } = require('child_process'); + let entries; + try { + const output = execSync( + `gh api repos/${marketplace.source}/contents --jq "[.[] | select(.type == \\"dir\\") | .name]"`, + { encoding: 'utf8', timeout: 15000 } + ).trim(); + entries = JSON.parse(output); + } catch (err) { + fatal(`Could not browse ${marketplace.source} ΓÇö is the GitHub CLI installed and authenticated?\n ${err.message}`); + } + + if (!entries || entries.length === 0) { + console.log(`${DIM}No plugins found in ${marketplace.source}${RESET}`); + process.exit(0); + } + + console.log(`\n${BOLD}Plugins in ${marketplace.name}${RESET} (${marketplace.source}):\n`); + for (const entry of entries) { + console.log(` ≡ƒôª ${entry}`); + } + console.log(`\n${DIM}${entries.length} plugin(s) available${RESET}\n`); + process.exit(0); + } + + fatal(`Unknown action: ${action}. Usage: squad plugin marketplace add|remove|list|browse`); +} + +// --- Export subcommand --- +if (cmd === 'export') { + const teamMd = path.join(dest, '.ai-team', 'team.md'); + if (!fs.existsSync(teamMd)) { + fatal('No squad found ΓÇö run init first'); + } + + const manifest = { + version: '1.0', + exported_at: new Date().toISOString(), + squad_version: pkg.version, + casting: {}, + agents: {}, + skills: [] + }; + + // Read casting state + const castingDir = path.join(dest, '.ai-team', 'casting'); + for (const file of ['registry.json', 'policy.json', 'history.json']) { + const filePath = path.join(castingDir, file); + try { + if (fs.existsSync(filePath)) { + manifest.casting[file.replace('.json', '')] = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Warning: could not read casting/${file}: ${err.message}`); + } + } + + // Read agents + const agentsDir = path.join(dest, '.ai-team', 'agents'); + try { + if (fs.existsSync(agentsDir)) { + for (const entry of fs.readdirSync(agentsDir)) { + const agentDir = path.join(agentsDir, entry); + if (!fs.statSync(agentDir).isDirectory()) continue; + const agent = {}; + const charterPath = path.join(agentDir, 'charter.md'); + const historyPath = path.join(agentDir, 'history.md'); + if (fs.existsSync(charterPath)) agent.charter = fs.readFileSync(charterPath, 'utf8'); + if (fs.existsSync(historyPath)) agent.history = fs.readFileSync(historyPath, 'utf8'); + manifest.agents[entry] = agent; + } + } + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Warning: could not read agents: ${err.message}`); + } + + // Read skills + const skillsDir = path.join(dest, '.ai-team', 'skills'); + try { + if (fs.existsSync(skillsDir)) { + for (const entry of fs.readdirSync(skillsDir)) { + const skillFile = path.join(skillsDir, entry, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + manifest.skills.push(fs.readFileSync(skillFile, 'utf8')); + } + } + } + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Warning: could not read skills: ${err.message}`); + } + + // Determine output path + const outIdx = process.argv.indexOf('--out'); + const outPath = (outIdx !== -1 && process.argv[outIdx + 1]) + ? path.resolve(process.argv[outIdx + 1]) + : path.join(dest, 'squad-export.json'); + + try { + fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n'); + } catch (err) { + fatal(`Failed to write export file: ${err.message}`); + } + + const displayPath = path.relative(dest, outPath) || path.basename(outPath); + console.log(`${GREEN}Γ£ô${RESET} Exported squad to ${displayPath}`); + console.log(`${DIM}ΓÜá Review agent histories before sharing ΓÇö they may contain project-specific information${RESET}`); + process.exit(0); +} + +// --- Import subcommand --- +if (cmd === 'import') { + const importFile = process.argv[3]; + if (!importFile) { + fatal('Usage: squad import [--force]'); + } + + const importPath = path.resolve(importFile); + if (!fs.existsSync(importPath)) { + fatal(`Import file not found: ${importFile}`); + } + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(importPath, 'utf8')); + } catch (err) { + fatal(`Invalid JSON in import file: ${err.message}`); + } + + if (manifest.version !== '1.0') { + fatal(`Unsupported export version: ${manifest.version || 'missing'} (expected 1.0)`); + } + if (!manifest.agents || typeof manifest.agents !== 'object') { + fatal('Invalid export file: missing or invalid "agents" field'); + } + if (!manifest.casting || typeof manifest.casting !== 'object') { + fatal('Invalid export file: missing or invalid "casting" field'); + } + if (!Array.isArray(manifest.skills)) { + fatal('Invalid export file: missing or invalid "skills" field'); + } + + const aiTeamDir = path.join(dest, '.ai-team'); + const hasForce = process.argv.includes('--force'); + + // Collision detection + if (fs.existsSync(aiTeamDir)) { + if (!hasForce) { + fatal('A squad already exists here. Use --force to replace (current squad will be archived).'); + } + // Archive existing squad + const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '-').slice(0, 19); + const archiveDir = path.join(dest, `.ai-team-archive-${ts}`); + fs.renameSync(aiTeamDir, archiveDir); + } + + // Create directory structure + fs.mkdirSync(path.join(aiTeamDir, 'casting'), { recursive: true }); + fs.mkdirSync(path.join(aiTeamDir, 'decisions', 'inbox'), { recursive: true }); + fs.mkdirSync(path.join(aiTeamDir, 'orchestration-log'), { recursive: true }); + fs.mkdirSync(path.join(aiTeamDir, 'log'), { recursive: true }); + fs.mkdirSync(path.join(aiTeamDir, 'skills'), { recursive: true }); + + // Write empty project-specific files + fs.writeFileSync(path.join(aiTeamDir, 'decisions.md'), ''); + fs.writeFileSync(path.join(aiTeamDir, 'team.md'), ''); + + // Write casting state + for (const [key, value] of Object.entries(manifest.casting)) { + fs.writeFileSync(path.join(aiTeamDir, 'casting', `${key}.json`), JSON.stringify(value, null, 2) + '\n'); + } + + // Determine source project name from filename + const sourceProject = path.basename(importPath, '.json'); + const importDate = new Date().toISOString().split('T')[0]; + + // Write agents + const agentNames = Object.keys(manifest.agents); + for (const name of agentNames) { + const agent = manifest.agents[name]; + const agentDir = path.join(aiTeamDir, 'agents', name); + fs.mkdirSync(agentDir, { recursive: true }); + + if (agent.charter) { + fs.writeFileSync(path.join(agentDir, 'charter.md'), agent.charter); + } + + // History split: separate portable knowledge from project learnings + let historyContent = ''; + if (agent.history) { + historyContent = splitHistory(agent.history, sourceProject); + } + historyContent = `≡ƒôî Imported from ${sourceProject} on ${importDate}. Portable knowledge carried over; project learnings from previous project preserved below.\n\n` + historyContent; + fs.writeFileSync(path.join(agentDir, 'history.md'), historyContent); + } + + // Write skills + for (const skillContent of manifest.skills) { + 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); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); + } + + // Determine universe for messaging + let universe = 'unknown'; + if (manifest.casting.policy && manifest.casting.policy.universe) { + universe = manifest.casting.policy.universe; + } + + // Output + console.log(`${GREEN}Γ£ô${RESET} Imported squad from ${path.basename(importPath)}`); + console.log(` ${agentNames.length} agents: ${agentNames.join(', ')}`); + console.log(` ${manifest.skills.length} skills imported`); + console.log(` Casting: ${universe} universe preserved`); + console.log(); + console.log(`${DIM}ΓÜá Project-specific learnings are marked in agent histories ΓÇö review if needed${RESET}`); + console.log(); + console.log(`Next steps:`); + console.log(` 1. Open Copilot and select Squad`); + console.log(` 2. Tell the team about this project ΓÇö they'll adapt`); + console.log(); + process.exit(0); +} + +// Split history into portable knowledge and project learnings +function splitHistory(history, sourceProject) { + const lines = history.split('\n'); + const portable = []; + const projectLearnings = []; + + // Sections that are project-specific by nature + const projectSectionPatterns = [ + /^##\s*key file paths/i, + /^##\s*sprint/i, + /^##\s*pr\s*#/i, + /^##\s*file system/i, + /^##\s*session/i, + /^###\s*key file paths/i, + /^###\s*sprint/i, + /^###\s*pr\s*#/i, + /^###\s*file system/i, + /^###\s*session/i, + ]; + + // Sections that are portable by nature + const portableSectionPatterns = [ + /^##\s*learnings/i, + /^##\s*portable knowledge/i, + /^###\s*runtime architecture/i, + /^###\s*windows compatibility/i, + /^###\s*critical paths/i, + /^###\s*forwardability/i, + /^##\s*team updates/i, + ]; + + let currentSection = 'portable'; + let inProjectSection = false; + + for (const line of lines) { + // Check if this line starts a new section + if (/^#{1,3}\s/.test(line)) { + const isProjectSection = projectSectionPatterns.some(p => p.test(line)); + const isPortableSection = portableSectionPatterns.some(p => p.test(line)); + + if (isProjectSection) { + inProjectSection = true; + } else if (isPortableSection) { + inProjectSection = false; + } + // Lines starting with ≡ƒôî are team updates ΓÇö portable + } + + if (inProjectSection) { + projectLearnings.push(line); + } else { + portable.push(line); + } + } + + let result = ''; + if (portable.length > 0) { + result += portable.join('\n'); + } + if (projectLearnings.length > 0) { + result += `\n\n## Project Learnings (from import ΓÇö ${sourceProject})\n\n`; + result += projectLearnings.join('\n'); + } + return result; +} + +// Validate source files exist +const agentSrcCheck = path.join(root, '.github', 'agents', 'squad.agent.md'); +const templatesSrcCheck = path.join(root, 'templates'); +if (!fs.existsSync(agentSrcCheck)) { + fatal(`Source file missing: .github/agents/squad.agent.md ΓÇö installation may be corrupted`); +} +if (!fs.existsSync(templatesSrcCheck) || !fs.statSync(templatesSrcCheck).isDirectory()) { + fatal(`Source directory missing or corrupted: templates/ ΓÇö installation may be corrupted`); +} + +// Validate destination is writable +try { + fs.accessSync(dest, fs.constants.W_OK); +} catch { + fatal(`Cannot write to ${dest} ΓÇö check directory permissions`); +} + +const isUpgrade = cmd === 'upgrade'; + +// Stamp version into squad.agent.md after copying +function stampVersion(filePath) { + let content = fs.readFileSync(filePath, 'utf8'); + // Replace version field + content = content.replace(/^version:\s*"[^"]*"/m, `version: "${pkg.version}"`); + // Replace version in name field to show in agent picker UI + // Handles both "Squad" and "Squad (vX.Y.Z)" formats + content = content.replace(/^name:\s*Squad(?:\s*\([^)]*\))?$/m, `name: Squad (v${pkg.version})`); + fs.writeFileSync(filePath, content); +} + +// Read version from squad.agent.md frontmatter +function readInstalledVersion(filePath) { + try { + if (!fs.existsSync(filePath)) return null; + const content = fs.readFileSync(filePath, 'utf8'); + const match = content.match(/^version:\s*"([^"]+)"/m); + return match ? match[1] : '0.0.0'; + } catch { + return '0.0.0'; + } +} + +// Compare semver strings: -1 (ab) +function compareSemver(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) < (pb[i] || 0)) return -1; + if ((pa[i] || 0) > (pb[i] || 0)) return 1; + } + return 0; +} + +// Migration registry ΓÇö additive-only operations keyed by version +const migrations = [ + { + version: '0.2.0', + description: 'Create .ai-team/skills/ directory', + run(dest) { + const skillsDir = path.join(dest, '.ai-team', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + } + }, + { + version: '0.4.0', + description: 'Create .ai-team/plugins/ directory', + run(dest) { + const pluginsDir = path.join(dest, '.ai-team', 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true }); + } + } +]; + +// Run migrations applicable for upgrading from oldVersion to newVersion +function runMigrations(dest, oldVersion) { + const applicable = migrations + .filter(m => compareSemver(m.version, oldVersion) > 0) + .sort((a, b) => compareSemver(a.version, b.version)); + for (const m of applicable) { + try { + m.run(dest); + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Migration failed (${m.version}: ${m.description}): ${err.message}`); + } + } + return applicable.length; +} + +// Copy agent file (Squad-owned ΓÇö overwrite on upgrade) +const agentSrc = path.join(root, '.github', 'agents', 'squad.agent.md'); +const agentDest = path.join(dest, '.github', 'agents', 'squad.agent.md'); + +// Capture old version BEFORE any writes (used for delta reporting + migration filtering) +const oldVersion = isUpgrade ? readInstalledVersion(agentDest) : null; + +if (isUpgrade) { + const isAlreadyCurrent = oldVersion && oldVersion !== '0.0.0' && compareSemver(oldVersion, pkg.version) === 0; + + if (isAlreadyCurrent) { + // Still run missing migrations in case a prior upgrade was interrupted + runMigrations(dest, oldVersion); + + // Even if already current, update copilot-instructions.md if @copilot is enabled + const copilotInstructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); + const copilotInstructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); + const teamMd = path.join(dest, '.ai-team', 'team.md'); + const copilotEnabled = fs.existsSync(teamMd) + && fs.readFileSync(teamMd, 'utf8').includes('≡ƒñû Coding Agent'); + if (copilotEnabled && fs.existsSync(copilotInstructionsSrc)) { + fs.mkdirSync(path.dirname(copilotInstructionsDest), { recursive: true }); + fs.copyFileSync(copilotInstructionsSrc, copilotInstructionsDest); + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} .github/copilot-instructions.md`); + } + + // Refresh squad-owned files even when version matches + const workflowsSrc = path.join(root, 'templates', 'workflows'); + const workflowsDest = path.join(dest, '.github', 'workflows'); + if (fs.existsSync(workflowsSrc) && fs.statSync(workflowsSrc).isDirectory()) { + const wfFiles = fs.readdirSync(workflowsSrc).filter(f => f.endsWith('.yml')); + fs.mkdirSync(workflowsDest, { recursive: true }); + for (const file of wfFiles) { + fs.copyFileSync(path.join(workflowsSrc, file), path.join(workflowsDest, file)); + } + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} squad workflows (${wfFiles.length} files)`); + } + + // Refresh squad.agent.md + try { + fs.mkdirSync(path.dirname(agentDest), { recursive: true }); + fs.copyFileSync(agentSrc, agentDest); + stampVersion(agentDest); + } catch (err) { + // Non-fatal in early-exit path + } + + console.log(`${GREEN}Γ£ô${RESET} Already up to date (v${pkg.version})`); + process.exit(0); + } + + try { + fs.mkdirSync(path.dirname(agentDest), { recursive: true }); + fs.copyFileSync(agentSrc, agentDest); + stampVersion(agentDest); + } catch (err) { + fatal(`Failed to upgrade squad.agent.md: ${err.message}`); + } + + const fromLabel = oldVersion === '0.0.0' || !oldVersion ? 'unknown' : oldVersion; + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} coordinator from ${fromLabel} to ${pkg.version}`); +} else if (fs.existsSync(agentDest)) { + console.log(`${DIM}squad.agent.md already exists ΓÇö skipping (run 'upgrade' to update)${RESET}`); +} else { + try { + fs.mkdirSync(path.dirname(agentDest), { recursive: true }); + fs.copyFileSync(agentSrc, agentDest); + stampVersion(agentDest); + } catch (err) { + fatal(`Failed to create squad.agent.md: ${err.message}`); + } + console.log(`${GREEN}Γ£ô${RESET} .github/agents/squad.agent.md (v${pkg.version})`); +} + +// Pre-create drop-box, orchestration-log, casting, skills, and plugins directories (additive-only) +const inboxDir = path.join(dest, '.ai-team', 'decisions', 'inbox'); +const orchLogDir = path.join(dest, '.ai-team', 'orchestration-log'); +const castingDir = path.join(dest, '.ai-team', 'casting'); +const skillsDir = path.join(dest, '.ai-team', 'skills'); +const pluginsDir = path.join(dest, '.ai-team', 'plugins'); +try { + fs.mkdirSync(inboxDir, { recursive: true }); + fs.mkdirSync(orchLogDir, { recursive: true }); + fs.mkdirSync(castingDir, { recursive: true }); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.mkdirSync(pluginsDir, { recursive: true }); +} catch (err) { + fatal(`Failed to create .ai-team/ directories: ${err.message}`); +} + +// Copy starter skills (skip if any skills already exist) +if (!isUpgrade) { + const skillsSrc = path.join(root, 'templates', 'skills'); + if (fs.existsSync(skillsSrc) && fs.readdirSync(skillsDir).length === 0) { + copyRecursive(skillsSrc, skillsDir); + console.log(`${GREEN}Γ£ô${RESET} .ai-team/skills/ (starter skills)`); + } +} + + +// Create sample MCP config (skip if .copilot/mcp-config.json already exists) +if (!isUpgrade) { + const mcpDir = path.join(dest, '.copilot'); + const mcpConfigPath = path.join(mcpDir, 'mcp-config.json'); + if (!fs.existsSync(mcpConfigPath)) { + try { + fs.mkdirSync(mcpDir, { recursive: true }); + const mcpSample = { + mcpServers: { + "EXAMPLE-trello": { + command: "npx", + args: ["-y", "@trello/mcp-server"], + env: { + TRELLO_API_KEY: "${TRELLO_API_KEY}", + TRELLO_TOKEN: "${TRELLO_TOKEN}" + } + } + } + }; + fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpSample, null, 2) + '\n'); + console.log(`${GREEN}Γ£ô${RESET} .copilot/mcp-config.json (MCP sample ΓÇö rename EXAMPLE-trello to enable)`); + } catch (err) { + // Non-fatal ΓÇö MCP config is optional + } + } else { + console.log(`${DIM}mcp-config.json already exists ΓÇö skipping${RESET}`); + } +} + +// Copy default ceremonies config +const ceremoniesDest = path.join(dest, '.ai-team', 'ceremonies.md'); +if (!fs.existsSync(ceremoniesDest)) { + const ceremoniesSrc = path.join(root, 'templates', 'ceremonies.md'); + fs.copyFileSync(ceremoniesSrc, ceremoniesDest); + console.log(`${GREEN}Γ£ô${RESET} .ai-team/ceremonies.md`); +} else { + console.log(`${DIM}ceremonies.md already exists ΓÇö skipping${RESET}`); +} + +// copilot-instructions.md ΓÇö managed by `squad copilot` subcommand +// On upgrade, update if @copilot is enabled on the team +const copilotInstructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); +const copilotInstructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); +if (isUpgrade) { + const teamMd = path.join(dest, '.ai-team', 'team.md'); + const copilotEnabled = fs.existsSync(teamMd) + && fs.readFileSync(teamMd, 'utf8').includes('≡ƒñû Coding Agent'); + if (copilotEnabled && fs.existsSync(copilotInstructionsSrc)) { + fs.mkdirSync(path.dirname(copilotInstructionsDest), { recursive: true }); + fs.copyFileSync(copilotInstructionsSrc, copilotInstructionsDest); + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} .github/copilot-instructions.md`); + } +} + +// Append merge=union rules for append-only .ai-team/ files +const gitattributes = path.join(dest, '.gitattributes'); +const unionRules = [ + '.ai-team/decisions.md merge=union', + '.ai-team/agents/*/history.md merge=union', + '.ai-team/log/** merge=union', + '.ai-team/orchestration-log/** merge=union', +]; +const existing = fs.existsSync(gitattributes) ? fs.readFileSync(gitattributes, 'utf8') : ''; +const missing = unionRules.filter(rule => !existing.includes(rule)); +if (missing.length) { + const block = (existing && !existing.endsWith('\n') ? '\n' : '') + + '# Squad: union merge for append-only team state files\n' + + missing.join('\n') + '\n'; + fs.appendFileSync(gitattributes, block); + console.log(`${GREEN}Γ£ô${RESET} .gitattributes (merge=union rules)`); +} else { + console.log(`${DIM}.gitattributes merge rules already present ΓÇö skipping${RESET}`); +} + +// Copy templates (Squad-owned ΓÇö overwrite on upgrade) +const templatesSrc = path.join(root, 'templates'); +const templatesDest = path.join(dest, '.ai-team-templates'); + +if (isUpgrade) { + copyRecursive(templatesSrc, templatesDest); + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} .ai-team-templates/`); + + // Run migrations applicable for this version jump + runMigrations(dest, oldVersion || '0.0.0'); +} else if (fs.existsSync(templatesDest)) { + console.log(`${DIM}.ai-team-templates/ already exists ΓÇö skipping (run 'upgrade' to update)${RESET}`); +} else { + copyRecursive(templatesSrc, templatesDest); + console.log(`${GREEN}Γ£ô${RESET} .ai-team-templates/`); +} + +// Copy workflow templates (Squad-owned ΓÇö overwrite on upgrade) +const workflowsSrc = path.join(root, 'templates', 'workflows'); +const workflowsDest = path.join(dest, '.github', 'workflows'); + +if (fs.existsSync(workflowsSrc) && fs.statSync(workflowsSrc).isDirectory()) { + const workflowFiles = fs.readdirSync(workflowsSrc).filter(f => f.endsWith('.yml')); + + if (isUpgrade) { + fs.mkdirSync(workflowsDest, { recursive: true }); + for (const file of workflowFiles) { + fs.copyFileSync(path.join(workflowsSrc, file), path.join(workflowsDest, file)); + } + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} squad workflow files (${workflowFiles.length} workflows)`); + } else { + fs.mkdirSync(workflowsDest, { recursive: true }); + let copied = 0; + for (const file of workflowFiles) { + const destFile = path.join(workflowsDest, file); + if (fs.existsSync(destFile)) { + console.log(`${DIM}${file} already exists ΓÇö skipping (run 'upgrade' to update)${RESET}`); + } else { + fs.copyFileSync(path.join(workflowsSrc, file), destFile); + console.log(`${GREEN}Γ£ô${RESET} .github/workflows/${file}`); + copied++; + } + } + if (copied === 0 && workflowFiles.length > 0) { + console.log(`${DIM}all squad workflows already exist ΓÇö skipping${RESET}`); + } + } +} + +if (isUpgrade) { + console.log(`\n${DIM}.ai-team/ untouched ΓÇö your team state is safe${RESET}`); + + // Hint about new features available after upgrade + const teamMd = path.join(dest, '.ai-team', 'team.md'); + const copilotEnabled = fs.existsSync(teamMd) + && fs.readFileSync(teamMd, 'utf8').includes('≡ƒñû Coding Agent'); + if (!copilotEnabled) { + console.log(`\n${BOLD}New:${RESET} @copilot coding agent support is now available.`); + console.log(` Run ${BOLD}npx github:bradygaster/squad copilot${RESET} to add it to your team.`); + } +} + +console.log(); +console.log(`${BOLD}Squad is ${isUpgrade ? 'upgraded' : 'ready'}.${RESET}${isUpgrade ? ` (v${pkg.version})` : ''}`); +console.log(); +if (!isUpgrade) { + console.log(`Next steps:`); + console.log(` 1. Open Copilot: ${DIM}copilot${RESET}`); + console.log(` 2. Select ${BOLD}Squad${RESET} from the /agents list`); + console.log(` 3. Tell it what you're building`); + console.log(); +} diff --git a/index-theirs.js b/index-theirs.js new file mode 100644 index 000000000..75ed969cd --- /dev/null +++ b/index-theirs.js @@ -0,0 +1,1472 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const GREEN = '\x1b[32m'; +const RED = '\x1b[31m'; +const YELLOW = '\x1b[33m'; +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; + +function fatal(msg) { + console.error(`${RED}Γ£ù${RESET} ${msg}`); + process.exit(1); +} + +// Detect squad directory ΓÇö .squad/ first, fall back to .ai-team/ +function detectSquadDir(dest) { + const squadDir = path.join(dest, '.squad'); + const aiTeamDir = path.join(dest, '.ai-team'); + + if (fs.existsSync(squadDir)) { + return { path: squadDir, name: '.squad', isLegacy: false }; + } + if (fs.existsSync(aiTeamDir)) { + return { path: aiTeamDir, name: '.ai-team', isLegacy: true }; + } + // Default for new installations + return { path: squadDir, name: '.squad', isLegacy: false }; +} + +function showDeprecationWarning() { + console.log(); + console.log(`${YELLOW}ΓÜá∩╕Å DEPRECATION: .ai-team/ is deprecated and will be removed in v1.0.0${RESET}`); + console.log(`${YELLOW} Run 'npx github:bradygaster/squad upgrade --migrate-directory' to migrate to .squad/${RESET}`); + console.log(`${YELLOW} Details: https://github.com/bradygaster/squad/issues/101${RESET}`); + console.log(); +} + +process.on('uncaughtException', (err) => { + fatal(`Unexpected error: ${err.message}`); +}); + +const root = __dirname; +const dest = process.cwd(); +const pkg = require(path.join(root, 'package.json')); +const cmd = process.argv[2]; + +// --version / --help +if (cmd === '--version' || cmd === '-v') { + console.log(pkg.version); + process.exit(0); +} + +if (cmd === '--help' || cmd === '-h' || cmd === 'help') { + console.log(`\n${BOLD}squad${RESET} v${pkg.version} ΓÇö Add an AI agent team to any project\n`); + console.log(`Usage: npx github:bradygaster/squad [command]\n`); + console.log(`Commands:`); + console.log(` ${BOLD}(default)${RESET} Initialize Squad (skip files that already exist)`); + console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); + console.log(` Overwrites: squad.agent.md, .ai-team-templates/`); + console.log(` Never touches: .squad/ or .ai-team/ (your team state)`); + console.log(` Flags: --migrate-directory (rename .ai-team/ ΓåÆ .squad/)`); + console.log(` ${BOLD}copilot${RESET} Add/remove the Copilot coding agent (@copilot)`); + console.log(` Usage: copilot [--off] [--auto-assign]`); + console.log(` ${BOLD}plugin${RESET} Manage plugin marketplaces`); + console.log(` Usage: plugin marketplace add|remove|list|browse`); + console.log(` ${BOLD}export${RESET} Export squad to a portable JSON snapshot`); + console.log(` Default: squad-export.json (use --out to override)`); + console.log(` ${BOLD}import${RESET} Import squad from an export file`); + console.log(` Usage: import [--force]`); + console.log(` ${BOLD}scrub-emails${RESET} Remove email addresses from Squad state files`); + console.log(` Usage: scrub-emails [directory] (default: .ai-team/)`); + console.log(` ${BOLD}help${RESET} Show this help message`); + console.log(`\nFlags:`); + console.log(` ${BOLD}--version, -v${RESET} Print version`); + console.log(` ${BOLD}--help, -h${RESET} Show help`); + console.log(`\nInsider channel: npx github:bradygaster/squad#insider\n`); + process.exit(0); +} + +function copyRecursive(src, target) { + try { + if (fs.statSync(src).isDirectory()) { + fs.mkdirSync(target, { recursive: true }); + for (const entry of fs.readdirSync(src)) { + copyRecursive(path.join(src, entry), path.join(target, entry)); + } + } else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(src, target); + } + } catch (err) { + fatal(`Failed to copy ${path.relative(root, src)}: ${err.message}`); + } +} + +// Scrub email addresses from Squad state files +function scrubEmailsFromDirectory(dirPath) { + const EMAIL_PATTERN = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g; + const NAME_WITH_EMAIL_PATTERN = /([a-zA-Z0-9_-]+)\s*\(([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\)/g; + + const scrubbedFiles = []; + const filesToScrub = [ + 'team.md', + 'decisions.md', + 'routing.md', + 'ceremonies.md' + ]; + + // Scrub root-level files + for (const file of filesToScrub) { + const filePath = path.join(dirPath, file); + if (fs.existsSync(filePath)) { + try { + let content = fs.readFileSync(filePath, 'utf8'); + let modified = false; + + // Replace "name (email)" ΓåÆ "name" + const beforeNameEmail = content; + content = content.replace(NAME_WITH_EMAIL_PATTERN, '$1'); + if (content !== beforeNameEmail) modified = true; + + // Replace bare emails in identity contexts (but preserve in URLs and code examples) + const lines = content.split('\n'); + const scrubbed = lines.map(line => { + // Skip lines that look like URLs, code blocks, or examples + if (line.includes('http://') || line.includes('https://') || + line.includes('```') || line.includes('example.com') || + line.trim().startsWith('//') || line.trim().startsWith('#')) { + return line; + } + // Scrub emails from identity/attribution contexts + const before = line; + const after = line.replace(EMAIL_PATTERN, '[email scrubbed]'); + if (before !== after) modified = true; + return after; + }); + + if (modified) { + fs.writeFileSync(filePath, scrubbed.join('\n')); + scrubbedFiles.push(path.relative(dirPath, filePath)); + } + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Failed to scrub ${file}: ${err.message}`); + } + } + } + + // Scrub agent history files + const agentsDir = path.join(dirPath, 'agents'); + if (fs.existsSync(agentsDir)) { + try { + for (const agentName of fs.readdirSync(agentsDir)) { + const historyPath = path.join(agentsDir, agentName, 'history.md'); + if (fs.existsSync(historyPath)) { + let content = fs.readFileSync(historyPath, 'utf8'); + let modified = false; + + // Replace "name (email)" ΓåÆ "name" + const beforeNameEmail = content; + content = content.replace(NAME_WITH_EMAIL_PATTERN, '$1'); + if (content !== beforeNameEmail) modified = true; + + // Scrub bare emails carefully + const lines = content.split('\n'); + const scrubbed = lines.map(line => { + // Skip URLs, code, examples + if (line.includes('http://') || line.includes('https://') || + line.includes('```') || line.includes('example.com') || + line.trim().startsWith('//') || line.trim().startsWith('#')) { + return line; + } + const before = line; + const after = line.replace(EMAIL_PATTERN, '[email scrubbed]'); + if (before !== after) modified = true; + return after; + }); + + if (modified) { + fs.writeFileSync(historyPath, scrubbed.join('\n')); + scrubbedFiles.push(path.relative(dirPath, historyPath)); + } + } + } + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Failed to scrub agent histories: ${err.message}`); + } + } + + // Scrub log files + const logDir = path.join(dirPath, 'log'); + if (fs.existsSync(logDir)) { + try { + const logFiles = fs.readdirSync(logDir).filter(f => f.endsWith('.md') || f.endsWith('.txt') || f.endsWith('.log')); + for (const file of logFiles) { + const filePath = path.join(logDir, file); + let content = fs.readFileSync(filePath, 'utf8'); + const before = content; + content = content.replace(NAME_WITH_EMAIL_PATTERN, '$1'); + content = content.replace(EMAIL_PATTERN, '[email scrubbed]'); + if (content !== before) { + fs.writeFileSync(filePath, content); + scrubbedFiles.push(path.relative(dirPath, filePath)); + } + } + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Failed to scrub log files: ${err.message}`); + } + } + + return scrubbedFiles; +} + +// Detect project type by checking for marker files in the target directory +function detectProjectType(dir) { + if (fs.existsSync(path.join(dir, 'package.json'))) return 'npm'; + if (fs.existsSync(path.join(dir, 'go.mod'))) return 'go'; + if (fs.existsSync(path.join(dir, 'requirements.txt')) || + fs.existsSync(path.join(dir, 'pyproject.toml'))) return 'python'; + if (fs.existsSync(path.join(dir, 'pom.xml')) || + fs.existsSync(path.join(dir, 'build.gradle')) || + fs.existsSync(path.join(dir, 'build.gradle.kts'))) return 'java'; + try { + const entries = fs.readdirSync(dir); + if (entries.some(e => e.endsWith('.csproj') || e.endsWith('.sln'))) return 'dotnet'; + } catch {} + return 'unknown'; +} + +// Workflows that contain Node.js/npm-specific commands and need project-type adaptation +const PROJECT_TYPE_SENSITIVE_WORKFLOWS = new Set([ + 'squad-ci.yml', + 'squad-release.yml', + 'squad-preview.yml', + 'squad-insider-release.yml', + 'squad-docs.yml', +]); + +// Generate a stub workflow for non-npm projects so no broken npm commands run +function generateProjectWorkflowStub(workflowFile, projectType) { + const typeLabel = projectType === 'unknown' + ? 'Project type was not detected' + : projectType + ' project'; + const todoBuildCmd = projectType === 'unknown' + ? '# TODO: Project type was not detected ΓÇö add your build/test commands here' + : '# TODO: Add your ' + projectType + ' build/test commands here'; + const buildHints = [ + ' # Go: go test ./...', + ' # Python: pip install -r requirements.txt && pytest', + ' # .NET: dotnet test', + ' # Java (Maven): mvn test', + ' # Java (Gradle): ./gradlew test', + ].join('\n'); + + if (workflowFile === 'squad-ci.yml') { + return 'name: Squad CI\n' + + '# ' + typeLabel + ' ΓÇö configure build/test commands below\n\n' + + 'on:\n' + + ' pull_request:\n' + + ' branches: [dev, preview, main, insider]\n' + + ' types: [opened, synchronize, reopened]\n' + + ' push:\n' + + ' branches: [dev, insider]\n\n' + + 'permissions:\n' + + ' contents: read\n\n' + + 'jobs:\n' + + ' test:\n' + + ' runs-on: ubuntu-latest\n' + + ' steps:\n' + + ' - uses: actions/checkout@v4\n\n' + + ' - name: Build and test\n' + + ' run: |\n' + + ' ' + todoBuildCmd + '\n' + + buildHints + '\n' + + ' echo "No build commands configured ΓÇö update squad-ci.yml"\n'; + } + + if (workflowFile === 'squad-release.yml') { + return 'name: Squad Release\n' + + '# ' + typeLabel + ' ΓÇö configure build, test, and release commands below\n\n' + + 'on:\n' + + ' push:\n' + + ' branches: [main]\n\n' + + 'permissions:\n' + + ' contents: write\n\n' + + 'jobs:\n' + + ' release:\n' + + ' runs-on: ubuntu-latest\n' + + ' steps:\n' + + ' - uses: actions/checkout@v4\n' + + ' with:\n' + + ' fetch-depth: 0\n\n' + + ' - name: Build and test\n' + + ' run: |\n' + + ' ' + todoBuildCmd + '\n' + + buildHints + '\n' + + ' echo "No build commands configured ΓÇö update squad-release.yml"\n\n' + + ' - name: Create release\n' + + ' env:\n' + + ' GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n' + + ' run: |\n' + + ' # TODO: Add your release commands here (e.g., git tag, gh release create)\n' + + ' echo "No release commands configured ΓÇö update squad-release.yml"\n'; + } + + if (workflowFile === 'squad-preview.yml') { + return 'name: Squad Preview Validation\n' + + '# ' + typeLabel + ' ΓÇö configure build, test, and validation commands below\n\n' + + 'on:\n' + + ' push:\n' + + ' branches: [preview]\n\n' + + 'permissions:\n' + + ' contents: read\n\n' + + 'jobs:\n' + + ' validate:\n' + + ' runs-on: ubuntu-latest\n' + + ' steps:\n' + + ' - uses: actions/checkout@v4\n\n' + + ' - name: Build and test\n' + + ' run: |\n' + + ' ' + todoBuildCmd + '\n' + + buildHints + '\n' + + ' echo "No build commands configured ΓÇö update squad-preview.yml"\n\n' + + ' - name: Validate\n' + + ' run: |\n' + + ' # TODO: Add pre-release validation commands here\n' + + ' echo "No validation commands configured ΓÇö update squad-preview.yml"\n'; + } + + if (workflowFile === 'squad-insider-release.yml') { + return 'name: Squad Insider Release\n' + + '# ' + typeLabel + ' ΓÇö configure build, test, and insider release commands below\n\n' + + 'on:\n' + + ' push:\n' + + ' branches: [insider]\n\n' + + 'permissions:\n' + + ' contents: write\n\n' + + 'jobs:\n' + + ' release:\n' + + ' runs-on: ubuntu-latest\n' + + ' steps:\n' + + ' - uses: actions/checkout@v4\n' + + ' with:\n' + + ' fetch-depth: 0\n\n' + + ' - name: Build and test\n' + + ' run: |\n' + + ' ' + todoBuildCmd + '\n' + + buildHints + '\n' + + ' echo "No build commands configured ΓÇö update squad-insider-release.yml"\n\n' + + ' - name: Create insider release\n' + + ' env:\n' + + ' GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n' + + ' run: |\n' + + ' # TODO: Add your insider/pre-release commands here\n' + + ' echo "No release commands configured ΓÇö update squad-insider-release.yml"\n'; + } + + if (workflowFile === 'squad-docs.yml') { + return 'name: Squad Docs ΓÇö Build & Deploy\n' + + '# ' + typeLabel + ' ΓÇö configure documentation build commands below\n\n' + + 'on:\n' + + ' workflow_dispatch:\n' + + ' push:\n' + + ' branches: [preview]\n' + + ' paths:\n' + + " - 'docs/**'\n" + + " - '.github/workflows/squad-docs.yml'\n\n" + + 'permissions:\n' + + ' contents: read\n' + + ' pages: write\n' + + ' id-token: write\n\n' + + 'jobs:\n' + + ' build:\n' + + ' runs-on: ubuntu-latest\n' + + ' steps:\n' + + ' - uses: actions/checkout@v4\n\n' + + ' - name: Build docs\n' + + ' run: |\n' + + ' # TODO: Add your documentation build commands here\n' + + ' # This workflow is optional ΓÇö remove or customize it for your project\n' + + ' echo "No docs build commands configured ΓÇö update or remove squad-docs.yml"\n'; + } + + return null; +} + +// Write a workflow file: verbatim copy for npm projects, stub for others +function writeWorkflowFile(file, srcPath, destPath, projectType) { + if (projectType !== 'npm' && PROJECT_TYPE_SENSITIVE_WORKFLOWS.has(file)) { + const stub = generateProjectWorkflowStub(file, projectType); + if (stub) { + fs.writeFileSync(destPath, stub); + return; + } + } + fs.copyFileSync(srcPath, destPath); +} + +// --- Email scrubbing subcommand --- +if (cmd === 'scrub-emails') { + const targetDir = process.argv[3] || path.join(dest, '.ai-team'); + + if (!fs.existsSync(targetDir)) { + fatal(`Directory not found: ${targetDir}`); + } + + console.log(`${DIM}Scanning ${path.relative(dest, targetDir)} for email addresses...${RESET}`); + const scrubbedFiles = scrubEmailsFromDirectory(targetDir); + + if (scrubbedFiles.length === 0) { + console.log(`${GREEN}Γ£ô${RESET} No email addresses found ΓÇö all clean`); + } else { + console.log(`${GREEN}Γ£ô${RESET} Scrubbed email addresses from ${scrubbedFiles.length} file(s):`); + for (const file of scrubbedFiles) { + console.log(` ${BOLD}${file}${RESET}`); + } + console.log(); + console.log(`${YELLOW}ΓÜá∩╕Å Note: Git history may still contain email addresses${RESET}`); + console.log(`${YELLOW} For a complete scrub, use git-filter-repo:${RESET}`); + console.log(`${YELLOW} https://github.com/newren/git-filter-repo${RESET}`); + } + console.log(); + process.exit(0); +} + +// --- Copilot subcommand --- +if (cmd === 'copilot') { + const teamMd = path.join(dest, '.ai-team', 'team.md'); + if (!fs.existsSync(teamMd)) { + fatal('No squad found ΓÇö run init first, then add the copilot agent.'); + } + + const isOff = process.argv.includes('--off'); + const autoAssign = process.argv.includes('--auto-assign'); + let content = fs.readFileSync(teamMd, 'utf8'); + const hasCopilot = content.includes('≡ƒñû Coding Agent'); + + if (isOff) { + if (!hasCopilot) { + console.log(`${DIM}Copilot coding agent is not on the team ΓÇö nothing to remove${RESET}`); + process.exit(0); + } + // Remove the Coding Agent section + content = content.replace(/\n## Coding Agent\n[\s\S]*?(?=\n## |\n*$)/, ''); + fs.writeFileSync(teamMd, content); + console.log(`${GREEN}Γ£ô${RESET} Removed @copilot from the team roster`); + + // Remove copilot-instructions.md + const instructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); + if (fs.existsSync(instructionsDest)) { + fs.unlinkSync(instructionsDest); + console.log(`${GREEN}Γ£ô${RESET} Removed .github/copilot-instructions.md`); + } + process.exit(0); + } + + // Adding copilot + if (hasCopilot) { + // Update auto-assign if requested + if (autoAssign) { + content = content.replace('', ''); + fs.writeFileSync(teamMd, content); + console.log(`${GREEN}Γ£ô${RESET} Enabled @copilot auto-assign`); + } else { + console.log(`${DIM}@copilot is already on the team${RESET}`); + } + process.exit(0); + } + + // Add Coding Agent section before Project Context + const autoAssignValue = autoAssign ? 'true' : 'false'; + const copilotSection = ` +## Coding Agent + + + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| @copilot | Coding Agent | ΓÇö | ≡ƒñû Coding Agent | + +### Capabilities + +**≡ƒƒó Good fit ΓÇö auto-route when enabled:** +- Bug fixes with clear reproduction steps +- Test coverage (adding missing tests, fixing flaky tests) +- Lint/format fixes and code style cleanup +- Dependency updates and version bumps +- Small isolated features with clear specs +- Boilerplate/scaffolding generation +- Documentation fixes and README updates + +**≡ƒƒí Needs review ΓÇö route to @copilot but flag for squad member PR review:** +- Medium features with clear specs and acceptance criteria +- Refactoring with existing test coverage +- API endpoint additions following established patterns +- Migration scripts with well-defined schemas + +**≡ƒö┤ Not suitable ΓÇö route to squad member instead:** +- Architecture decisions and system design +- Multi-system integration requiring coordination +- Ambiguous requirements needing clarification +- Security-critical changes (auth, encryption, access control) +- Performance-critical paths requiring benchmarking +- Changes requiring cross-team discussion + +`; + + // Insert before "## Project Context" if it exists, otherwise append + if (content.includes('## Project Context')) { + content = content.replace('## Project Context', copilotSection + '## Project Context'); + } else { + content = content.trimEnd() + '\n' + copilotSection; + } + + fs.writeFileSync(teamMd, content); + console.log(`${GREEN}Γ£ô${RESET} Added @copilot (Coding Agent) to team roster`); + if (autoAssign) { + console.log(`${GREEN}Γ£ô${RESET} Auto-assign enabled ΓÇö squad-labeled issues will be assigned to @copilot`); + } + + // Copy copilot-instructions.md + const instructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); + const instructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); + if (fs.existsSync(instructionsSrc)) { + fs.mkdirSync(path.dirname(instructionsDest), { recursive: true }); + fs.copyFileSync(instructionsSrc, instructionsDest); + console.log(`${GREEN}Γ£ô${RESET} .github/copilot-instructions.md`); + } + + console.log(); + console.log(`${BOLD}@copilot is on the team.${RESET}`); + console.log(`The coding agent will pick up issues matching its capability profile.`); + if (!autoAssign) { + console.log(`Run with ${BOLD}--auto-assign${RESET} to auto-assign @copilot on squad-labeled issues.`); + } + console.log(); + console.log(`${BOLD}Required:${RESET} Add a classic PAT (repo scope) as a repo secret for auto-assignment:`); + console.log(` 1. Create token: ${DIM}https://github.com/settings/tokens/new${RESET}`); + console.log(` 2. Set secret: ${DIM}gh secret set COPILOT_ASSIGN_TOKEN${RESET}`); + console.log(); + process.exit(0); +} + +// --- Plugin marketplace subcommand --- +if (cmd === 'plugin') { + const subCmd = process.argv[3]; + const action = process.argv[4]; + + if (subCmd !== 'marketplace' || !action) { + fatal('Usage: squad plugin marketplace add|remove|list|browse'); + } + + const squadDirInfo = detectSquadDir(dest); + const pluginsDir = path.join(squadDirInfo.path, 'plugins'); + const marketplacesFile = path.join(pluginsDir, 'marketplaces.json'); + + function readMarketplaces() { + if (!fs.existsSync(marketplacesFile)) return { marketplaces: [] }; + try { + return JSON.parse(fs.readFileSync(marketplacesFile, 'utf8')); + } catch { + return { marketplaces: [] }; + } + } + + function writeMarketplaces(data) { + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync(marketplacesFile, JSON.stringify(data, null, 2) + '\n'); + } + + if (action === 'add') { + const source = process.argv[5]; + if (!source || !source.includes('/')) { + fatal('Usage: squad plugin marketplace add '); + } + const data = readMarketplaces(); + const name = source.split('/').pop(); + if (data.marketplaces.some(m => m.source === source)) { + console.log(`${DIM}${source} is already registered${RESET}`); + process.exit(0); + } + data.marketplaces.push({ + name, + source, + added_at: new Date().toISOString() + }); + writeMarketplaces(data); + console.log(`${GREEN}Γ£ô${RESET} Registered marketplace: ${BOLD}${name}${RESET} (${source})`); + process.exit(0); + } + + if (action === 'remove') { + const name = process.argv[5]; + if (!name) { + fatal('Usage: squad plugin marketplace remove '); + } + const data = readMarketplaces(); + const before = data.marketplaces.length; + data.marketplaces = data.marketplaces.filter(m => m.name !== name); + if (data.marketplaces.length === before) { + fatal(`Marketplace "${name}" not found`); + } + writeMarketplaces(data); + console.log(`${GREEN}Γ£ô${RESET} Removed marketplace: ${BOLD}${name}${RESET}`); + process.exit(0); + } + + if (action === 'list') { + const data = readMarketplaces(); + if (data.marketplaces.length === 0) { + console.log(`${DIM}No marketplaces registered${RESET}`); + console.log(`\nAdd one with: ${BOLD}squad plugin marketplace add ${RESET}`); + process.exit(0); + } + console.log(`\n${BOLD}Registered marketplaces:${RESET}\n`); + for (const m of data.marketplaces) { + const date = m.added_at ? ` ${DIM}(added ${m.added_at.split('T')[0]})${RESET}` : ''; + console.log(` ${BOLD}${m.name}${RESET} ΓåÆ ${m.source}${date}`); + } + console.log(); + process.exit(0); + } + + if (action === 'browse') { + const name = process.argv[5]; + if (!name) { + fatal('Usage: squad plugin marketplace browse '); + } + const data = readMarketplaces(); + const marketplace = data.marketplaces.find(m => m.name === name); + if (!marketplace) { + fatal(`Marketplace "${name}" not found. Run "squad plugin marketplace list" to see registered marketplaces.`); + } + + // Browse the marketplace repo for plugins using gh CLI + const { execSync } = require('child_process'); + let entries; + try { + const output = execSync( + `gh api repos/${marketplace.source}/contents --jq "[.[] | select(.type == \\"dir\\") | .name]"`, + { encoding: 'utf8', timeout: 15000 } + ).trim(); + entries = JSON.parse(output); + } catch (err) { + fatal(`Could not browse ${marketplace.source} ΓÇö is the GitHub CLI installed and authenticated?\n ${err.message}`); + } + + if (!entries || entries.length === 0) { + console.log(`${DIM}No plugins found in ${marketplace.source}${RESET}`); + process.exit(0); + } + + console.log(`\n${BOLD}Plugins in ${marketplace.name}${RESET} (${marketplace.source}):\n`); + for (const entry of entries) { + console.log(` ≡ƒôª ${entry}`); + } + console.log(`\n${DIM}${entries.length} plugin(s) available${RESET}\n`); + process.exit(0); + } + + fatal(`Unknown action: ${action}. Usage: squad plugin marketplace add|remove|list|browse`); +} + +// --- Export subcommand --- +if (cmd === 'export') { + const teamMd = path.join(dest, '.ai-team', 'team.md'); + if (!fs.existsSync(teamMd)) { + fatal('No squad found ΓÇö run init first'); + } + + const manifest = { + version: '1.0', + exported_at: new Date().toISOString(), + squad_version: pkg.version, + casting: {}, + agents: {}, + skills: [] + }; + + // Read casting state + const castingDir = path.join(dest, '.ai-team', 'casting'); + for (const file of ['registry.json', 'policy.json', 'history.json']) { + const filePath = path.join(castingDir, file); + try { + if (fs.existsSync(filePath)) { + manifest.casting[file.replace('.json', '')] = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Warning: could not read casting/${file}: ${err.message}`); + } + } + + // Read agents + const agentsDir = path.join(dest, '.ai-team', 'agents'); + try { + if (fs.existsSync(agentsDir)) { + for (const entry of fs.readdirSync(agentsDir)) { + const agentDir = path.join(agentsDir, entry); + if (!fs.statSync(agentDir).isDirectory()) continue; + const agent = {}; + const charterPath = path.join(agentDir, 'charter.md'); + const historyPath = path.join(agentDir, 'history.md'); + if (fs.existsSync(charterPath)) agent.charter = fs.readFileSync(charterPath, 'utf8'); + if (fs.existsSync(historyPath)) agent.history = fs.readFileSync(historyPath, 'utf8'); + manifest.agents[entry] = agent; + } + } + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Warning: could not read agents: ${err.message}`); + } + + // Read skills + const skillsDir = path.join(dest, '.ai-team', 'skills'); + try { + if (fs.existsSync(skillsDir)) { + for (const entry of fs.readdirSync(skillsDir)) { + const skillFile = path.join(skillsDir, entry, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + manifest.skills.push(fs.readFileSync(skillFile, 'utf8')); + } + } + } + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Warning: could not read skills: ${err.message}`); + } + + // Determine output path + const outIdx = process.argv.indexOf('--out'); + const outPath = (outIdx !== -1 && process.argv[outIdx + 1]) + ? path.resolve(process.argv[outIdx + 1]) + : path.join(dest, 'squad-export.json'); + + try { + fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n'); + } catch (err) { + fatal(`Failed to write export file: ${err.message}`); + } + + const displayPath = path.relative(dest, outPath) || path.basename(outPath); + console.log(`${GREEN}Γ£ô${RESET} Exported squad to ${displayPath}`); + console.log(`${DIM}ΓÜá Review agent histories before sharing ΓÇö they may contain project-specific information${RESET}`); + process.exit(0); +} + +// --- Import subcommand --- +if (cmd === 'import') { + const importFile = process.argv[3]; + if (!importFile) { + fatal('Usage: squad import [--force]'); + } + + const importPath = path.resolve(importFile); + if (!fs.existsSync(importPath)) { + fatal(`Import file not found: ${importFile}`); + } + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(importPath, 'utf8')); + } catch (err) { + fatal(`Invalid JSON in import file: ${err.message}`); + } + + if (manifest.version !== '1.0') { + fatal(`Unsupported export version: ${manifest.version || 'missing'} (expected 1.0)`); + } + if (!manifest.agents || typeof manifest.agents !== 'object') { + fatal('Invalid export file: missing or invalid "agents" field'); + } + if (!manifest.casting || typeof manifest.casting !== 'object') { + fatal('Invalid export file: missing or invalid "casting" field'); + } + if (!Array.isArray(manifest.skills)) { + fatal('Invalid export file: missing or invalid "skills" field'); + } + + const aiTeamDir = path.join(dest, '.ai-team'); + const hasForce = process.argv.includes('--force'); + + // Collision detection + if (fs.existsSync(aiTeamDir)) { + if (!hasForce) { + fatal('A squad already exists here. Use --force to replace (current squad will be archived).'); + } + // Archive existing squad + const ts = new Date().toISOString().replace(/:/g, '-').replace(/\./g, '-'); + const archiveDir = path.join(dest, `.ai-team-archive-${ts}`); + fs.renameSync(aiTeamDir, archiveDir); + } + + // Create directory structure + fs.mkdirSync(path.join(aiTeamDir, 'casting'), { recursive: true }); + fs.mkdirSync(path.join(aiTeamDir, 'decisions', 'inbox'), { recursive: true }); + fs.mkdirSync(path.join(aiTeamDir, 'orchestration-log'), { recursive: true }); + fs.mkdirSync(path.join(aiTeamDir, 'log'), { recursive: true }); + fs.mkdirSync(path.join(aiTeamDir, 'skills'), { recursive: true }); + + // Write empty project-specific files + fs.writeFileSync(path.join(aiTeamDir, 'decisions.md'), ''); + fs.writeFileSync(path.join(aiTeamDir, 'team.md'), ''); + + // Write casting state + for (const [key, value] of Object.entries(manifest.casting)) { + fs.writeFileSync(path.join(aiTeamDir, 'casting', `${key}.json`), JSON.stringify(value, null, 2) + '\n'); + } + + // Determine source project name from filename + const sourceProject = path.basename(importPath, '.json'); + const importDate = new Date().toISOString(); + + // Write agents + const agentNames = Object.keys(manifest.agents); + for (const name of agentNames) { + const agent = manifest.agents[name]; + const agentDir = path.join(aiTeamDir, 'agents', name); + fs.mkdirSync(agentDir, { recursive: true }); + + if (agent.charter) { + fs.writeFileSync(path.join(agentDir, 'charter.md'), agent.charter); + } + + // History split: separate portable knowledge from project learnings + let historyContent = ''; + if (agent.history) { + historyContent = splitHistory(agent.history, sourceProject); + } + historyContent = `≡ƒôî Imported from ${sourceProject} on ${importDate}. Portable knowledge carried over; project learnings from previous project preserved below.\n\n` + historyContent; + fs.writeFileSync(path.join(agentDir, 'history.md'), historyContent); + } + + // Write skills + for (const skillContent of manifest.skills) { + 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); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); + } + + // Determine universe for messaging + let universe = 'unknown'; + if (manifest.casting.policy && manifest.casting.policy.universe) { + universe = manifest.casting.policy.universe; + } + + // Output + console.log(`${GREEN}Γ£ô${RESET} Imported squad from ${path.basename(importPath)}`); + console.log(` ${agentNames.length} agents: ${agentNames.join(', ')}`); + console.log(` ${manifest.skills.length} skills imported`); + console.log(` Casting: ${universe} universe preserved`); + console.log(); + console.log(`${DIM}ΓÜá Project-specific learnings are marked in agent histories ΓÇö review if needed${RESET}`); + console.log(); + console.log(`Next steps:`); + console.log(` 1. Open Copilot and select Squad`); + console.log(` 2. Tell the team about this project ΓÇö they'll adapt`); + console.log(); + process.exit(0); +} + +// Split history into portable knowledge and project learnings +function splitHistory(history, sourceProject) { + const lines = history.split('\n'); + const portable = []; + const projectLearnings = []; + + // Sections that are project-specific by nature + const projectSectionPatterns = [ + /^##\s*key file paths/i, + /^##\s*sprint/i, + /^##\s*pr\s*#/i, + /^##\s*file system/i, + /^##\s*session/i, + /^###\s*key file paths/i, + /^###\s*sprint/i, + /^###\s*pr\s*#/i, + /^###\s*file system/i, + /^###\s*session/i, + ]; + + // Sections that are portable by nature + const portableSectionPatterns = [ + /^##\s*learnings/i, + /^##\s*portable knowledge/i, + /^###\s*runtime architecture/i, + /^###\s*windows compatibility/i, + /^###\s*critical paths/i, + /^###\s*forwardability/i, + /^##\s*team updates/i, + ]; + + let currentSection = 'portable'; + let inProjectSection = false; + + for (const line of lines) { + // Check if this line starts a new section + if (/^#{1,3}\s/.test(line)) { + const isProjectSection = projectSectionPatterns.some(p => p.test(line)); + const isPortableSection = portableSectionPatterns.some(p => p.test(line)); + + if (isProjectSection) { + inProjectSection = true; + } else if (isPortableSection) { + inProjectSection = false; + } + // Lines starting with ≡ƒôî are team updates ΓÇö portable + } + + if (inProjectSection) { + projectLearnings.push(line); + } else { + portable.push(line); + } + } + + let result = ''; + if (portable.length > 0) { + result += portable.join('\n'); + } + if (projectLearnings.length > 0) { + result += `\n\n## Project Learnings (from import ΓÇö ${sourceProject})\n\n`; + result += projectLearnings.join('\n'); + } + return result; +} + +// Validate source files exist +const agentSrcCheck = path.join(root, '.github', 'agents', 'squad.agent.md'); +const templatesSrcCheck = path.join(root, 'templates'); +if (!fs.existsSync(agentSrcCheck)) { + fatal(`Source file missing: .github/agents/squad.agent.md ΓÇö installation may be corrupted`); +} +if (!fs.existsSync(templatesSrcCheck) || !fs.statSync(templatesSrcCheck).isDirectory()) { + fatal(`Source directory missing or corrupted: templates/ ΓÇö installation may be corrupted`); +} + +// Validate destination is writable +try { + fs.accessSync(dest, fs.constants.W_OK); +} catch { + fatal(`Cannot write to ${dest} ΓÇö check directory permissions`); +} + +const isUpgrade = cmd === 'upgrade'; +const isSelfUpgrade = isUpgrade && process.argv.includes('--self'); +const isMigrateDirectory = isUpgrade && process.argv.includes('--migrate-directory'); + +// Handle --migrate-directory flag: rename .ai-team/ to .squad/ +if (isMigrateDirectory) { + const aiTeamDir = path.join(dest, '.ai-team'); + const squadDir = path.join(dest, '.squad'); + + if (!fs.existsSync(aiTeamDir)) { + fatal('No .ai-team/ directory found ΓÇö nothing to migrate.'); + } + + if (fs.existsSync(squadDir)) { + fatal('.squad/ directory already exists ΓÇö migration appears to be complete.'); + } + + console.log(`${DIM}Migrating .ai-team/ ΓåÆ .squad/...${RESET}`); + + try { + // Rename directory + fs.renameSync(aiTeamDir, squadDir); + console.log(`${GREEN}Γ£ô${RESET} Renamed .ai-team/ ΓåÆ .squad/`); + + // Update .gitattributes + const gitattributes = path.join(dest, '.gitattributes'); + if (fs.existsSync(gitattributes)) { + let content = fs.readFileSync(gitattributes, 'utf8'); + const updated = content.replace(/\.ai-team\//g, '.squad/'); + if (content !== updated) { + fs.writeFileSync(gitattributes, updated); + console.log(`${GREEN}Γ£ô${RESET} Updated .gitattributes`); + } + } + + // Update .gitignore if it exists + const gitignore = path.join(dest, '.gitignore'); + if (fs.existsSync(gitignore)) { + let content = fs.readFileSync(gitignore, 'utf8'); + const updated = content.replace(/\.ai-team\//g, '.squad/'); + if (content !== updated) { + fs.writeFileSync(gitignore, updated); + console.log(`${GREEN}Γ£ô${RESET} Updated .gitignore`); + } + } + + // Scrub email addresses from migrated files + console.log(`${DIM}Scrubbing email addresses from .squad/ files...${RESET}`); + const scrubbedFiles = scrubEmailsFromDirectory(squadDir); + if (scrubbedFiles.length > 0) { + console.log(`${GREEN}Γ£ô${RESET} Scrubbed email addresses from ${scrubbedFiles.length} file(s)`); + } else { + console.log(`${GREEN}Γ£ô${RESET} No email addresses found`); + } + + console.log(); + console.log(`${BOLD}Migration complete.${RESET}`); + console.log(`${DIM}Commit the change:${RESET}`); + console.log(` git add -A`); + console.log(` git commit -m "chore: migrate .ai-team/ ΓåÆ .squad/"`); + console.log(); + + } catch (err) { + fatal(`Migration failed: ${err.message}`); + } + + process.exit(0); +} + +// Stamp version into squad.agent.md after copying +function stampVersion(filePath) { + let content = fs.readFileSync(filePath, 'utf8'); + // Replace version in HTML comment (must come immediately after frontmatter closing ---) + content = content.replace(//m, ``); + // Replace version in the Identity section's Version line + content = content.replace(/- \*\*Version:\*\* [0-9.]+(?:-[a-z]+)?/m, `- **Version:** ${pkg.version}`); + fs.writeFileSync(filePath, content); +} + +// Read version from squad.agent.md HTML comment +function readInstalledVersion(filePath) { + try { + if (!fs.existsSync(filePath)) return null; + const content = fs.readFileSync(filePath, 'utf8'); + // Try to read from HTML comment first (new format) + const commentMatch = content.match(//); + if (commentMatch) return commentMatch[1]; + // Fallback: try old frontmatter format for backward compatibility during upgrade + const frontmatterMatch = content.match(/^version:\s*"([^"]+)"/m); + return frontmatterMatch ? frontmatterMatch[1] : '0.0.0'; + } catch { + return '0.0.0'; + } +} + +// Compare semver strings: -1 (ab) +function compareSemver(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) < (pb[i] || 0)) return -1; + if ((pa[i] || 0) > (pb[i] || 0)) return 1; + } + return 0; +} + +// Migration registry ΓÇö additive-only operations keyed by version +const migrations = [ + { + version: '0.2.0', + description: 'Create .ai-team/skills/ directory', + run(dest) { + const skillsDir = path.join(dest, '.ai-team', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + } + }, + { + version: '0.4.0', + description: 'Create .ai-team/plugins/ directory', + run(dest) { + const pluginsDir = path.join(dest, '.ai-team', 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true }); + } + }, + { + version: '0.5.0', + description: 'Scrub email addresses from Squad state files (privacy fix)', + run(dest) { + const aiTeamDir = path.join(dest, '.ai-team'); + if (fs.existsSync(aiTeamDir)) { + const scrubbedFiles = scrubEmailsFromDirectory(aiTeamDir); + if (scrubbedFiles.length > 0) { + console.log(`${GREEN}Γ£ô${RESET} Privacy migration: scrubbed email addresses from ${scrubbedFiles.length} file(s)`); + } + } + } + } +]; + +// Run migrations applicable for upgrading from oldVersion to newVersion +function runMigrations(dest, oldVersion) { + const applicable = migrations + .filter(m => compareSemver(m.version, oldVersion) > 0) + .sort((a, b) => compareSemver(a.version, b.version)); + for (const m of applicable) { + try { + m.run(dest); + } catch (err) { + console.error(`${RED}Γ£ù${RESET} Migration failed (${m.version}: ${m.description}): ${err.message}`); + } + } + return applicable.length; +} + +// Copy agent file (Squad-owned ΓÇö overwrite on upgrade) +const agentSrc = path.join(root, '.github', 'agents', 'squad.agent.md'); +const agentDest = path.join(dest, '.github', 'agents', 'squad.agent.md'); + +// Handle --self flag: refresh .ai-team/ from templates (for squad repo itself) +if (isSelfUpgrade) { + const aiTeamDir = path.join(dest, '.ai-team'); + if (!fs.existsSync(aiTeamDir)) { + fatal('No .ai-team/ directory found. Run init first, or remove --self flag.'); + } + + console.log(`${DIM}Refreshing .ai-team/ from templates (squad --self mode)...${RESET}`); + + // Refresh team-wide files from templates + const filesToRefresh = [ + { src: 'team.md', dest: 'team.md' }, + { src: 'routing.md', dest: 'routing.md' }, + { src: 'ceremonies.md', dest: 'ceremonies.md' } + ]; + + for (const file of filesToRefresh) { + const srcPath = path.join(root, 'templates', file.src); + const destPath = path.join(aiTeamDir, file.dest); + if (fs.existsSync(srcPath)) { + fs.copyFileSync(srcPath, destPath); + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}refreshed${RESET} .ai-team/${file.dest}`); + } + } + + // Refresh skills directory (don't touch agent directories ΓÇö preserve history) + const skillsSrc = path.join(root, 'templates', 'skills'); + const skillsDest = path.join(aiTeamDir, 'skills'); + if (fs.existsSync(skillsSrc)) { + copyRecursive(skillsSrc, skillsDest); + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}refreshed${RESET} .ai-team/skills/`); + } + + console.log(); + console.log(`${BOLD}Squad repo refreshed.${RESET}`); + console.log(`${DIM}Agent histories preserved ΓÇö only templates and skills updated${RESET}`); + console.log(); + showDeprecationWarning(); + process.exit(0); +} + +// Detect project type once for use throughout init/upgrade workflow generation +const projectType = detectProjectType(dest); + +// Capture old version BEFORE any writes (used for delta reporting + migration filtering) +const oldVersion = isUpgrade ? readInstalledVersion(agentDest) : null; + +if (isUpgrade) { + const isAlreadyCurrent = oldVersion && oldVersion !== '0.0.0' && compareSemver(oldVersion, pkg.version) === 0; + + if (isAlreadyCurrent) { + // Still run missing migrations in case a prior upgrade was interrupted + runMigrations(dest, oldVersion); + + // Even if already current, update copilot-instructions.md if @copilot is enabled + const copilotInstructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); + const copilotInstructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); + const teamMd = path.join(dest, '.ai-team', 'team.md'); + const copilotEnabled = fs.existsSync(teamMd) + && fs.readFileSync(teamMd, 'utf8').includes('≡ƒñû Coding Agent'); + if (copilotEnabled && fs.existsSync(copilotInstructionsSrc)) { + fs.mkdirSync(path.dirname(copilotInstructionsDest), { recursive: true }); + fs.copyFileSync(copilotInstructionsSrc, copilotInstructionsDest); + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} .github/copilot-instructions.md`); + } + + // Refresh squad-owned files even when version matches + const workflowsSrc = path.join(root, 'templates', 'workflows'); + const workflowsDest = path.join(dest, '.github', 'workflows'); + if (fs.existsSync(workflowsSrc) && fs.statSync(workflowsSrc).isDirectory()) { + const wfFiles = fs.readdirSync(workflowsSrc).filter(f => f.endsWith('.yml')); + fs.mkdirSync(workflowsDest, { recursive: true }); + for (const file of wfFiles) { + writeWorkflowFile(file, path.join(workflowsSrc, file), path.join(workflowsDest, file), projectType); + } + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} squad workflows (${wfFiles.length} files)`); + } + + // Refresh squad.agent.md + try { + fs.mkdirSync(path.dirname(agentDest), { recursive: true }); + fs.copyFileSync(agentSrc, agentDest); + stampVersion(agentDest); + } catch (err) { + // Non-fatal in early-exit path + } + + console.log(`${GREEN}Γ£ô${RESET} Already up to date (v${pkg.version})`); + process.exit(0); + } + + try { + fs.mkdirSync(path.dirname(agentDest), { recursive: true }); + fs.copyFileSync(agentSrc, agentDest); + stampVersion(agentDest); + } catch (err) { + fatal(`Failed to upgrade squad.agent.md: ${err.message}`); + } + + const fromLabel = oldVersion === '0.0.0' || !oldVersion ? 'unknown' : oldVersion; + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} coordinator from ${fromLabel} to ${pkg.version}`); +} else if (fs.existsSync(agentDest)) { + console.log(`${DIM}squad.agent.md already exists ΓÇö skipping (run 'upgrade' to update)${RESET}`); +} else { + try { + fs.mkdirSync(path.dirname(agentDest), { recursive: true }); + fs.copyFileSync(agentSrc, agentDest); + stampVersion(agentDest); + } catch (err) { + fatal(`Failed to create squad.agent.md: ${err.message}`); + } + console.log(`${GREEN}Γ£ô${RESET} .github/agents/squad.agent.md (v${pkg.version})`); +} + +// Detect or determine squad directory (.squad/ for new, detect for upgrades) +// Detect squad directory for dual-path support (.squad/ or .ai-team/) +const squadInfo = (() => { + const squadDir = path.join(dest, '.squad'); + const aiTeamDir = path.join(dest, '.ai-team'); + + if (fs.existsSync(squadDir)) { + return { path: squadDir, name: '.squad', isLegacy: false }; + } + if (fs.existsSync(aiTeamDir)) { + return { path: aiTeamDir, name: '.ai-team', isLegacy: true }; + } + // Default for new installations + return { path: squadDir, name: '.squad', isLegacy: false }; +})(); + +// Show deprecation warning if using .ai-team/ (but not on new installs) +if (squadInfo.isLegacy) { + showDeprecationWarning(); +} + +// Pre-create drop-box, orchestration-log, casting, skills, plugins, and identity directories (additive-only) +const inboxDir = path.join(squadInfo.path, 'decisions', 'inbox'); +const orchLogDir = path.join(squadInfo.path, 'orchestration-log'); +const castingDir = path.join(squadInfo.path, 'casting'); +const skillsDir = path.join(squadInfo.path, 'skills'); +const pluginsDir = path.join(squadInfo.path, 'plugins'); +const identityDir = path.join(squadInfo.path, 'identity'); +try { + fs.mkdirSync(inboxDir, { recursive: true }); + fs.mkdirSync(orchLogDir, { recursive: true }); + fs.mkdirSync(castingDir, { recursive: true }); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.mkdirSync(identityDir, { recursive: true }); +} catch (err) { + fatal(`Failed to create ${squadInfo.name}/ directories: ${err.message}`); +} + +// Copy starter skills (skip if any skills already exist) +if (!isUpgrade) { + const skillsSrc = path.join(root, 'templates', 'skills'); + if (fs.existsSync(skillsSrc) && fs.readdirSync(skillsDir).length === 0) { + copyRecursive(skillsSrc, skillsDir); + console.log(`${GREEN}Γ£ô${RESET} ${squadInfo.name}/skills/ (starter skills)`); + } +} + +// Scaffold identity files (now.md, wisdom.md) ΓÇö both init and upgrade +const nowMdPath = path.join(identityDir, 'now.md'); +const wisdomMdPath = path.join(identityDir, 'wisdom.md'); + +if (!fs.existsSync(nowMdPath)) { + const nowTemplate = `--- +updated_at: ${new Date().toISOString()} +focus_area: Initial setup +active_issues: [] +--- + +# What We're Focused On + +Getting started. Updated by coordinator at session start. +`; + fs.mkdirSync(identityDir, { recursive: true }); + fs.writeFileSync(nowMdPath, nowTemplate); + console.log(`${GREEN}Γ£ô${RESET} ${squadInfo.name}/identity/now.md`); +} else if (isUpgrade) { + console.log(`${DIM}identity/now.md already exists ΓÇö skipping${RESET}`); +} + +if (!fs.existsSync(wisdomMdPath)) { + const wisdomTemplate = `--- +last_updated: ${new Date().toISOString()} +--- + +# Team Wisdom + +Reusable patterns and heuristics learned through work. NOT transcripts ΓÇö each entry is a distilled, actionable insight. + +## Patterns + + + +## Anti-Patterns + + +`; + fs.mkdirSync(identityDir, { recursive: true }); + fs.writeFileSync(wisdomMdPath, wisdomTemplate); + console.log(`${GREEN}Γ£ô${RESET} ${squadInfo.name}/identity/wisdom.md`); +} else if (isUpgrade) { + console.log(`${DIM}identity/wisdom.md already exists ΓÇö skipping${RESET}`); +} + +// Create sample MCP config (skip if .copilot/mcp-config.json already exists) +if (!isUpgrade) { + const mcpDir = path.join(dest, '.copilot'); + const mcpConfigPath = path.join(mcpDir, 'mcp-config.json'); + if (!fs.existsSync(mcpConfigPath)) { + try { + fs.mkdirSync(mcpDir, { recursive: true }); + const mcpSample = { + mcpServers: { + "EXAMPLE-trello": { + command: "npx", + args: ["-y", "@trello/mcp-server"], + env: { + TRELLO_API_KEY: "${TRELLO_API_KEY}", + TRELLO_TOKEN: "${TRELLO_TOKEN}" + } + } + } + }; + fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpSample, null, 2) + '\n'); + console.log(`${GREEN}Γ£ô${RESET} .copilot/mcp-config.json (MCP sample ΓÇö rename EXAMPLE-trello to enable)`); + } catch (err) { + // Non-fatal ΓÇö MCP config is optional + } + } else { + console.log(`${DIM}mcp-config.json already exists ΓÇö skipping${RESET}`); + } +} + +// Copy default ceremonies config +const ceremoniesDest = path.join(squadInfo.path, 'ceremonies.md'); +if (!fs.existsSync(ceremoniesDest)) { + const ceremoniesSrc = path.join(root, 'templates', 'ceremonies.md'); + fs.copyFileSync(ceremoniesSrc, ceremoniesDest); + console.log(`${GREEN}Γ£ô${RESET} ${squadInfo.name}/ceremonies.md`); +} else { + console.log(`${DIM}ceremonies.md already exists ΓÇö skipping${RESET}`); +} + +// copilot-instructions.md ΓÇö managed by `squad copilot` subcommand +// On upgrade, update if @copilot is enabled on the team +const copilotInstructionsSrc = path.join(root, 'templates', 'copilot-instructions.md'); +const copilotInstructionsDest = path.join(dest, '.github', 'copilot-instructions.md'); +if (isUpgrade) { + const teamMd = path.join(squadInfo.path, 'team.md'); + const copilotEnabled = fs.existsSync(teamMd) + && fs.readFileSync(teamMd, 'utf8').includes('≡ƒñû Coding Agent'); + if (copilotEnabled && fs.existsSync(copilotInstructionsSrc)) { + fs.mkdirSync(path.dirname(copilotInstructionsDest), { recursive: true }); + fs.copyFileSync(copilotInstructionsSrc, copilotInstructionsDest); + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} .github/copilot-instructions.md`); + } +} + +// Append merge=union rules for append-only .ai-team/ files +const gitattributes = path.join(dest, '.gitattributes'); +const unionRules = [ + '.ai-team/decisions.md merge=union', + '.ai-team/agents/*/history.md merge=union', + '.ai-team/log/** merge=union', + '.ai-team/orchestration-log/** merge=union', +]; +const existing = fs.existsSync(gitattributes) ? fs.readFileSync(gitattributes, 'utf8') : ''; +const missing = unionRules.filter(rule => !existing.includes(rule)); +if (missing.length) { + const block = (existing && !existing.endsWith('\n') ? '\n' : '') + + '# Squad: union merge for append-only team state files\n' + + missing.join('\n') + '\n'; + fs.appendFileSync(gitattributes, block); + console.log(`${GREEN}Γ£ô${RESET} .gitattributes (merge=union rules)`); +} else { + console.log(`${DIM}.gitattributes merge rules already present ΓÇö skipping${RESET}`); +} + +// Copy templates (Squad-owned ΓÇö overwrite on upgrade) +const templatesSrc = path.join(root, 'templates'); +const templatesDest = path.join(dest, '.ai-team-templates'); + +if (isUpgrade) { + copyRecursive(templatesSrc, templatesDest); + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} .ai-team-templates/`); + + // Run migrations applicable for this version jump + runMigrations(dest, oldVersion || '0.0.0'); +} else if (fs.existsSync(templatesDest)) { + console.log(`${DIM}.ai-team-templates/ already exists ΓÇö skipping (run 'upgrade' to update)${RESET}`); +} else { + copyRecursive(templatesSrc, templatesDest); + console.log(`${GREEN}Γ£ô${RESET} .ai-team-templates/`); +} + +// Copy workflow templates (Squad-owned ΓÇö overwrite on upgrade) +const workflowsSrc = path.join(root, 'templates', 'workflows'); +const workflowsDest = path.join(dest, '.github', 'workflows'); + +if (fs.existsSync(workflowsSrc) && fs.statSync(workflowsSrc).isDirectory()) { + const workflowFiles = fs.readdirSync(workflowsSrc).filter(f => f.endsWith('.yml')); + + if (isUpgrade) { + fs.mkdirSync(workflowsDest, { recursive: true }); + for (const file of workflowFiles) { + writeWorkflowFile(file, path.join(workflowsSrc, file), path.join(workflowsDest, file), projectType); + } + console.log(`${GREEN}Γ£ô${RESET} ${BOLD}upgraded${RESET} squad workflow files (${workflowFiles.length} workflows)`); + } else { + fs.mkdirSync(workflowsDest, { recursive: true }); + let copied = 0; + for (const file of workflowFiles) { + const destFile = path.join(workflowsDest, file); + if (fs.existsSync(destFile)) { + console.log(`${DIM}${file} already exists ΓÇö skipping (run 'upgrade' to update)${RESET}`); + } else { + writeWorkflowFile(file, path.join(workflowsSrc, file), destFile, projectType); + console.log(`${GREEN}Γ£ô${RESET} .github/workflows/${file}`); + copied++; + } + } + if (copied === 0 && workflowFiles.length > 0) { + console.log(`${DIM}all squad workflows already exist ΓÇö skipping${RESET}`); + } + } +} + +if (isUpgrade) { + // Scrub email addresses from existing squad directory + console.log(`${DIM}Scrubbing email addresses from ${squadInfo.name}/ files...${RESET}`); + const scrubResult = scrubEmailsFromDirectory(squadInfo.path); + const scrubbed = Array.isArray(scrubResult) ? scrubResult : []; + if (scrubbed.length > 0) { + console.log(`${GREEN}Γ£ô${RESET} Scrubbed email addresses from ${scrubbed.length} file(s)`); + } else { + console.log(`${GREEN}Γ£ô${RESET} No email addresses found`); + } + + console.log(`\n${DIM}${squadInfo.name}/ untouched ΓÇö your team state is safe${RESET}`); + + // Hint about new features available after upgrade + const teamMd = path.join(squadInfo.path, 'team.md'); + const copilotEnabled = fs.existsSync(teamMd) + && fs.readFileSync(teamMd, 'utf8').includes('≡ƒñû Coding Agent'); + if (!copilotEnabled) { + console.log(`\n${BOLD}New:${RESET} @copilot coding agent support is now available.`); + console.log(` Run ${BOLD}npx github:bradygaster/squad copilot${RESET} to add it to your team.`); + } +} + +console.log(); +console.log(`${BOLD}Squad is ${isUpgrade ? 'upgraded' : 'ready'}.${RESET}${isUpgrade ? ` (v${pkg.version})` : ''}`); +console.log(); +if (squadInfo.isLegacy) { + showDeprecationWarning(); +} +if (!isUpgrade) { + console.log(`Next steps:`); + console.log(` 1. Open Copilot: ${DIM}copilot${RESET}`); + console.log(` 2. Type ${BOLD}/agent${RESET} (CLI) or ${BOLD}/agents${RESET} (VS Code) and select ${BOLD}Squad${RESET}`); + console.log(` 3. Tell it what you're building`); + console.log(); +} else { + console.log(); +} diff --git a/index.js b/index.js index 8ba12a209..baa6f2153 100644 --- a/index.js +++ b/index.js @@ -64,6 +64,9 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') { console.log(` Flags: --migrate-directory (rename .ai-team/ → .squad/)`); console.log(` ${BOLD}copilot${RESET} Add/remove the Copilot coding agent (@copilot)`); console.log(` Usage: copilot [--off] [--auto-assign]`); + console.log(` ${BOLD}watch${RESET} Run Ralph's work monitor as a local polling process`); + console.log(` Usage: watch [--interval ]`); + console.log(` Default: checks every 10 minutes (Ctrl+C to stop)`); console.log(` ${BOLD}plugin${RESET} Manage plugin marketplaces`); console.log(` Usage: plugin marketplace add|remove|list|browse`); console.log(` ${BOLD}export${RESET} Export squad to a portable JSON snapshot`); @@ -96,6 +99,171 @@ function copyRecursive(src, target) { } } + +// --- Watch subcommand (Ralph local watchdog) --- +if (cmd === 'watch') { + const { execSync } = require('child_process'); + + const squadDirInfo = detectSquadDir(dest); + if (squadDirInfo.isLegacy) showDeprecationWarning(); + const teamMd = path.join(squadDirInfo.path, 'team.md'); + if (!fs.existsSync(teamMd)) { + fatal('No squad found — run init first.'); + } + + // Verify gh CLI is available + try { + execSync('gh --version', { stdio: 'pipe' }); + } catch { + fatal('gh CLI not found — install from https://cli.github.com'); + } + + // Parse --interval flag (default: 10 minutes) + const intervalIdx = process.argv.indexOf('--interval'); + const intervalMin = (intervalIdx !== -1 && process.argv[intervalIdx + 1]) + ? parseInt(process.argv[intervalIdx + 1], 10) + : 10; + + if (isNaN(intervalMin) || intervalMin < 1) { + fatal('--interval must be a positive number of minutes'); + } + + const content = fs.readFileSync(teamMd, 'utf8'); + + // Parse members from roster + function parseMembers(text) { + const lines = text.split('\n'); + const members = []; + let inMembersTable = false; + for (const line of lines) { + if (line.startsWith('## Members')) { inMembersTable = true; continue; } + if (inMembersTable && line.startsWith('## ')) break; + if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { + const cells = line.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 2 && !['Scribe', 'Ralph'].includes(cells[0])) { + members.push({ name: cells[0], role: cells[1], label: `squad:${cells[0].toLowerCase()}` }); + } + } + } + return members; + } + + const members = parseMembers(content); + if (members.length === 0) { + fatal('No squad members found in team.md'); + } + + const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot'); + const autoAssign = content.includes(''); + + console.log(`\n${BOLD}🔄 Ralph — Watch Mode${RESET}`); + console.log(`${DIM}Polling every ${intervalMin} minute(s) for squad work. Ctrl+C to stop.${RESET}\n`); + + function runCheck() { + const timestamp = new Date().toLocaleTimeString(); + try { + // Fetch open issues with squad label + const issuesJson = execSync( + 'gh issue list --label "squad" --state open --json number,title,labels,assignees --limit 20', + { stdio: 'pipe', encoding: 'utf8' } + ); + const issues = JSON.parse(issuesJson || '[]'); + + const memberLabels = members.map(m => m.label); + const untriaged = issues.filter(issue => { + const issueLabels = issue.labels.map(l => l.name); + return !memberLabels.some(ml => issueLabels.includes(ml)); + }); + + // Find unassigned squad:copilot issues + let unassignedCopilot = []; + if (hasCopilot && autoAssign) { + try { + const copilotJson = execSync( + 'gh issue list --label "squad:copilot" --state open --json number,title,assignees --limit 10', + { stdio: 'pipe', encoding: 'utf8' } + ); + const copilotIssues = JSON.parse(copilotJson || '[]'); + unassignedCopilot = copilotIssues.filter(i => !i.assignees || i.assignees.length === 0); + } catch { /* label may not exist */ } + } + + if (untriaged.length === 0 && unassignedCopilot.length === 0) { + console.log(`${DIM}[${timestamp}]${RESET} 📋 Board is clear — no pending work`); + return; + } + + // Triage untriaged issues + for (const issue of untriaged) { + const issueText = `${issue.title}`.toLowerCase(); + let assignedMember = null; + let reason = ''; + + for (const member of members) { + const role = member.role.toLowerCase(); + if ((role.includes('frontend') || role.includes('ui')) && + (issueText.includes('ui') || issueText.includes('frontend') || issueText.includes('css'))) { + assignedMember = member; reason = 'frontend/UI domain'; break; + } + if ((role.includes('backend') || role.includes('api') || role.includes('server')) && + (issueText.includes('api') || issueText.includes('backend') || issueText.includes('database'))) { + assignedMember = member; reason = 'backend/API domain'; break; + } + if ((role.includes('test') || role.includes('qa')) && + (issueText.includes('test') || issueText.includes('bug') || issueText.includes('fix'))) { + assignedMember = member; reason = 'testing/QA domain'; break; + } + } + + if (!assignedMember) { + const lead = members.find(m => + m.role.toLowerCase().includes('lead') || m.role.toLowerCase().includes('architect') + ); + if (lead) { assignedMember = lead; reason = 'no domain match — routed to Lead'; } + } + + if (assignedMember) { + try { + execSync(`gh issue edit ${issue.number} --add-label "${assignedMember.label}"`, { stdio: 'pipe' }); + console.log(`${GREEN}✓${RESET} [${timestamp}] Triaged #${issue.number} "${issue.title}" → ${assignedMember.name} (${reason})`); + } catch (e) { + console.error(`${RED}✗${RESET} [${timestamp}] Failed to label #${issue.number}: ${e.message}`); + } + } + } + + // Assign @copilot to unassigned copilot issues + for (const issue of unassignedCopilot) { + try { + execSync( + `gh issue edit ${issue.number} --add-assignee copilot-swe-agent`, + { stdio: 'pipe' } + ); + console.log(`${GREEN}✓${RESET} [${timestamp}] Assigned @copilot to #${issue.number} "${issue.title}"`); + } catch (e) { + console.error(`${RED}✗${RESET} [${timestamp}] Failed to assign @copilot to #${issue.number}: ${e.message}`); + } + } + + } catch (e) { + console.error(`${RED}✗${RESET} [${timestamp}] Check failed: ${e.message}`); + } + } + + // Run immediately, then on interval + runCheck(); + setInterval(runCheck, intervalMin * 60 * 1000); + + // Handle Ctrl+C gracefully + process.on('SIGINT', () => { + console.log(`\n${DIM}🔄 Ralph — Watch stopped${RESET}`); + process.exit(0); + }); + + // Prevent fall-through to init/upgrade logic + return; +} + // Scrub email addresses from Squad state files function scrubEmailsFromDirectory(dirPath) { const EMAIL_PATTERN = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g; @@ -423,6 +591,7 @@ if (cmd === 'scrub-emails') { } console.log(); process.exit(0); + } // --- Copilot subcommand --- diff --git a/templates/workflows/squad-main-guard.yml b/templates/workflows/squad-main-guard.yml index 2ae1e7fa5..7ea0dbe17 100644 --- a/templates/workflows/squad-main-guard.yml +++ b/templates/workflows/squad-main-guard.yml @@ -69,36 +69,36 @@ jobs: } // Check each file against forbidden path rules - // Allow removals — deleting forbidden files from protected branches is fine + // Allow removals ΓÇö deleting forbidden files from protected branches is fine const forbidden = files .filter(f => f.status !== 'removed') .map(f => f.filename) .filter(f => { - // .ai-team/** and .squad/** — ALL team state files, zero exceptions + // .ai-team/** and .squad/** ΓÇö ALL team state files, zero exceptions if (f === '.ai-team' || f.startsWith('.ai-team/') || f === '.squad' || f.startsWith('.squad/')) return true; - // .ai-team-templates/** — Squad's own templates, stay on dev + // .ai-team-templates/** ΓÇö Squad's own templates, stay on dev if (f === '.ai-team-templates' || f.startsWith('.ai-team-templates/')) return true; - // team-docs/** — ALL internal team docs, zero exceptions + // team-docs/** ΓÇö ALL internal team docs, zero exceptions if (f.startsWith('team-docs/')) return true; - // docs/proposals/** — internal design proposals, stay on dev + // docs/proposals/** ΓÇö internal design proposals, stay on dev if (f.startsWith('docs/proposals/')) return true; return false; }); if (forbidden.length === 0) { - core.info('✅ No forbidden paths found in PR — all clear.'); + core.info('Γ£à No forbidden paths found in PR ΓÇö all clear.'); return; } // Build a clear, actionable error message const lines = [ - '## 🚫 Forbidden files detected in PR to main', + '## ≡ƒÜ½ Forbidden files detected in PR to main', '', 'The following files must NOT be merged into `main`.', - '`.ai-team/` and `.squad/` are runtime team state — they belong on dev branches only.', - '`.ai-team-templates/` is Squad\'s internal planning — it belongs on dev branches only.', - '`team-docs/` is internal team content — it belongs on dev branches only.', - '`docs/proposals/` is internal design proposals — it belongs on dev branches only.', + '`.ai-team/` and `.squad/` are runtime team state ΓÇö they belong on dev branches only.', + '`.ai-team-templates/` is Squad\'s internal planning ΓÇö it belongs on dev branches only.', + '`team-docs/` is internal team content ΓÇö it belongs on dev branches only.', + '`docs/proposals/` is internal design proposals ΓÇö it belongs on dev branches only.', '', '### Forbidden files found:', '', @@ -121,7 +121,7 @@ jobs: 'git push', '```', '', - '> ⚠️ `.ai-team/` and `.squad/` are committed on `dev` and feature branches by design.', + '> ΓÜá∩╕Å `.ai-team/` and `.squad/` are committed on `dev` and feature branches by design.', '> The guard workflow is the enforcement mechanism that keeps these files off `main` and `preview`.', '> `git rm --cached` untracks them from this PR without deleting your local copies.', ];