From 83d02dbb0c0f0d45448fa0658c1ec603c3a9d396 Mon Sep 17 00:00:00 2001 From: bradygaster Date: Sun, 22 Mar 2026 15:52:48 -0700 Subject: [PATCH 1/3] chore(.squad): triage #525, backlog prioritization - Add orchestration log: 2026-03-22T22-45-flight-triage-525.md - Add session log: 2026-03-22T22-45-triage-session.md - Merge decision inbox (6 files) into decisions.md: - flight-triage-525.md (validated #525, P2 rating, sub-issue decomposition, top-5 v1 priorities) - eecom-economy-mode.md (economy mode design decision) - eecom-node-sqlite.md (Node 22.5.0+ hard-fail decision) - eecom-rate-limit-ux.md (rate limit detection and recovery) - procedures-economy-mode-governance.md (squad.agent.md governance proposal) - procedures-personal-squad-governance.md (consult mode governance proposal) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/flight/history.md | 8 + .squad/decisions.md | 486 ++++++++++++++++++ .squad/decisions/inbox/eecom-economy-mode.md | 37 -- .../procedures-economy-mode-governance.md | 126 ----- .../procedures-personal-squad-governance.md | 158 ------ 5 files changed, 494 insertions(+), 321 deletions(-) delete mode 100644 .squad/decisions/inbox/eecom-economy-mode.md delete mode 100644 .squad/decisions/inbox/procedures-economy-mode-governance.md delete mode 100644 .squad/decisions/inbox/procedures-personal-squad-governance.md diff --git a/.squad/agents/flight/history.md b/.squad/agents/flight/history.md index 438cba1bbb..36e50127d4 100644 --- a/.squad/agents/flight/history.md +++ b/.squad/agents/flight/history.md @@ -65,3 +65,11 @@ Created `.squad/skills/content-triage/SKILL.md` to codify the boundary heuristic ### Session 2 Summary (2026-03-22) Wave 1 architecture work on #329/#344: validated 20KB personal squad design doc, identified and patched 5 gaps, authored 19-task implementation plan spanning 4 future PRs. Implementation not yet started — deferred to future session. EECOM assigned Phase 1–2 (SDK + CLI), Procedures assigned Phase 3 (governance), Sims assigned Phase 4 (tests). + +### Worktree Gap Triage — #525 (2025-07-18) + +Community contributor joniba filed #525 identifying that Squad has full worktree *detection* but zero worktree *creation* in the coordinator/spawn flow. Validated all 10 claims — analysis is accurate. The reading infrastructure (resolveSquad() worktree detection, .gitattributes merge=union, boundary tests) is ~95% complete. The gap: ralph-commands.ts hardcodes `git checkout -b` in all 3 platform adapters (lines 50/71/92), coordinator never creates worktrees before spawn, no WORKTREE_PATH in prompts, and issue-lifecycle.md is referenced in squad.agent.md but doesn't exist. + +**Decision:** P2 — important but not v1-blocking. Broke into 5 sub-issues: (1) doc fix for missing issue-lifecycle.md (quick win → Procedures), (2) worktree variant in ralph-commands.ts (EECOM), (3) coordinator pre-spawn logic (Procedures + EECOM), (4) post-merge cleanup (EECOM), (5) architecture decision on heuristic (Flight). Sub-issue #1 ships immediately; #2–5 queue post-Wave-1 alongside SubSquads work where parallel execution becomes a hard requirement. + +**Backlog priority recommendation:** Top 5 for v1 = #508 (Ambient Personal Squad), #498 (remove .squad/ from VCS), #485 (Agent Spec & Validation), #481 (Typed StorageProvider), #347 (shore up init --sdk). Quick wins: #525 doc fix, #347. Deprioritize: manual verification debt (#418–421), long-term exploratory. A2A (#332–336) stays shelved per existing decision. diff --git a/.squad/decisions.md b/.squad/decisions.md index 9992fb61be..0e04695469 100644 --- a/.squad/decisions.md +++ b/.squad/decisions.md @@ -6138,3 +6138,489 @@ ESM module resolution uses dual-layer postinstall strategy: **Impact:** If users report ESM errors on Node 22/24, direct them to `squad doctor`. + +--- + +# Economy Mode Design — #500 + +**Date:** 2026-03-20 +**Author:** EECOM +**Issue:** #500 + +## Decision + +Economy mode is implemented as a modifier that shifts model selection at Layer 3 (task-aware auto) and Layer 4 (default fallback) only. Layers 0–2 (explicit user preferences) are never downgraded. + +## Model Map + +| Normal | Economy | Use case | +|--------|---------|----------| +| `claude-opus-4.6` | `claude-sonnet-4.5` | Architecture, review | +| `claude-sonnet-4.6` | `gpt-4.1` | Code writing | +| `claude-sonnet-4.5` | `gpt-4.1` | Code writing | +| `claude-haiku-4.5` | `gpt-4.1` | Docs, planning, mechanical | + +## Activation + +1. **Persistent:** `"economyMode": true` in `.squad/config.json` (survives sessions) +2. **Session:** `--economy` CLI flag (sets `SQUAD_ECONOMY_MODE=1` env var, current session only) +3. **Toggle command:** `squad economy on|off` writes to config.json + +## Hierarchy Integration + +Economy mode is a Layer 3/4 modifier — it does NOT override explicit preferences (Layers 0–2). This is intentional: if a user said "always use opus", economy mode respects that choice. + +## Implementation Points + +- `ECONOMY_MODEL_MAP` + `applyEconomyMode()` in `packages/squad-sdk/src/config/models.ts` +- `readEconomyMode()` + `writeEconomyMode()` in `packages/squad-sdk/src/config/models.ts` +- `resolveModel()` in `config/models.ts` accepts `economyMode?: boolean` option; reads from config if not provided +- `resolveModel()` in `agents/model-selector.ts` also supports `economyMode?: boolean` +- `squad economy [on|off]` command in `packages/squad-cli/src/cli/commands/economy.ts` +- `--economy` global flag in `cli-entry.ts` + +--- + +# Decision: Hard-fail on Node <22.5.0 at CLI Startup + +**Author:** EECOM +**Date:** 2026-03-21 +**Issue:** #502 (workshop blocker) + +## Context + +Workshop participants reported `ERR_UNKNOWN_BUILTIN_MODULE` when using Squad on Node <22.5.0. The `node:sqlite` built-in (used by the Copilot SDK for session storage) requires Node 22.5.0+. + +The previous approach — `try { await import('node:sqlite') } catch { warn and continue }` — let the process limp along until the SDK actually hit sqlite, producing a confusing crash deep in a stack trace. + +## Decision + +**Hard-fail at startup with a clear, actionable message.** If Node <22.5.0 is detected, Squad exits immediately (`process.exit(1)`) with: + +``` +✗ Squad requires Node.js ≥22.5.0 (you have v20.18.0). + node:sqlite (required by the Copilot SDK for session storage) was added in Node 22.5.0. + Upgrade at: https://nodejs.org/en/download +``` + +**Rationale:** +- Fail fast > fail cryptically later +- The message includes the exact version needed and where to upgrade +- `engines.node` updated to `>=22.5.0` in all package.json files — npm/npx will also warn at install time +- `squad doctor` now includes a Node version check so users can proactively diagnose + +## Alternatives Rejected + +- **Fallback to `better-sqlite3`:** Adds a native binary dependency. Complexity cost is not justified since Node 22.5.0+ is already 18+ months old. +- **Soft warn and continue:** The existing approach — proved to be a workshop blocker. + +--- + +# Rate Limit UX: Detect and Recover, Don't Hide + +**Date:** 2026-03-20 +**Author:** EECOM +**Issue:** #464 + +## Decision + +When Squad catches a rate limit error (HTTP 429, "rate limit", "quota exceeded"), surface it explicitly rather than hiding it under "Something went wrong processing your message." + +## Rationale + +Generic error messages fail the user in two ways: +1. They don't explain *why* the error happened (rate limit vs. network vs. bug) +2. They give no recovery path — the user is stuck with "run squad doctor" which previously showed nothing useful + +Rate limits are a **pivot point, not a dead end**. The user can unblock themselves immediately by switching to economy mode or a different model. + +## Implementation + +1. **`error-messages.ts`** — added `rateLimitGuidance()` and `extractRetryAfter()`. Rate limit guidance shows: + - Clear message: "Rate limit reached [for {model}]. Copilot has temporarily throttled your requests." + - Recovery: time until reset (if parseable), `squad economy on`, and config.json model override + +2. **`shell/index.ts` catch block** — detects rate limits via `instanceof RateLimitError` OR regex on the error message (`/rate.?limit|quota.*exceed|429/i`). Writes `.squad/rate-limit-status.json` on detection for doctor to read. + +3. **`doctor.ts`** — added `checkRateLimitStatus()`. Reads `.squad/rate-limit-status.json` and reports: + - `warn` if rate limit was recent (< 4h ago), with command to fix + - `pass` if stale (> 4h ago) + - Silent if no rate limit has been hit + +## Alternatives Considered + +- **Making a live API call in `squad doctor`** — rejected, adds latency and may itself be rate-limited +- **Just showing the raw error** — rejected, unhelpful wall of text +- **Writing to a separate log format** — rejected, JSON status file is simpler to read/update + +--- + +# Triage: #525 — Worktree Creation & Lifecycle Missing from Coordinator/Spawn Flow + +**Author:** Flight +**Date:** 2025-07-18 +**Status:** Triaged +**Priority:** P2 — Important, not v1-blocking +**Labels:** `squad:eecom`, `squad:procedures`, `squad:flight` + +## Validation + +Community contributor joniba's analysis is **accurate and thorough**. I validated all 10 claims: + +| Claim | Verdict | +|-------|---------| +| ralph-commands.ts hardcodes `git checkout -b` (all 3 adapters) | ✅ Confirmed — lines 50, 71, 92 | +| issue-lifecycle.md referenced but missing | ✅ Confirmed — two broken refs in squad.agent.md | +| squad.agent.md has Worktree Awareness section | ✅ Confirmed — lines 569–607 | +| git-workflow skill defaults to checkout -b | ✅ Confirmed — worktree is documented but separate path | +| resolveSquad() detects .git as file (worktree pointer) | ✅ Confirmed — resolution.ts line 66–93 | +| .gitattributes merge=union for append-only files | ✅ Confirmed — 5 entries | +| Tests for worktree boundary detection | ✅ Confirmed — 5+ tests | +| Coordinator creates worktrees before spawn | ❌ Confirmed missing | +| WORKTREE_PATH in spawn prompts | ❌ Confirmed missing | +| Post-merge worktree cleanup | ❌ Confirmed missing | + +**Summary:** The reading side (detection, path resolution, merge drivers, tests) is solid — approximately 95% of the worktree infrastructure exists. The gap is purely on the writing/orchestration side: nobody creates worktrees, and the coordinator doesn't know it should. + +## Impact Assessment + +**Who this affects:** Any user running parallel agents on the same repo. Today, two agents spawned simultaneously will both `git checkout -b` from the same working directory and clobber each other. + +**Why it's P2 not P1:** Most current Squad users run single-agent sequential workflows. Parallel multi-agent execution is the advanced case. The SubSquads/Workstreams design (#509–#511) will eventually need this, but those aren't in Wave 1. + +**Risk of deferral:** Low short-term, medium long-term. Community contributors noticing the gap means adoption is hitting this edge. If we defer past v1, it becomes tech debt that's harder to retrofit. + +## Scope Recommendation: Break Into Sub-Issues + +This is too broad for one issue. Recommended decomposition: + +1. **Doc fix: Create issue-lifecycle.md** (quick win, 1 hour) + Owner: `squad:procedures` + Fix the broken reference in squad.agent.md. Standalone — no code changes. + +2. **SDK: Add worktree branch-creation variant to ralph-commands.ts** + Owner: `squad:eecom` + Add `git worktree add` as an alternative to `git checkout -b` in all 3 platform adapters. Decision logic: single agent = checkout, parallel = worktree. + +3. **Coordinator: Pre-spawn worktree creation** + Owner: `squad:procedures`, `squad:eecom` + When coordinator detects parallel spawn, create worktree before dispatching agent. Pass WORKTREE_PATH in spawn context. + +4. **Lifecycle: Post-merge worktree cleanup** + Owner: `squad:eecom` + After PR merge, `git worktree remove` + prune. Could hook into Ralph's idle-watch. + +5. **Architecture decision: Worktree vs checkout heuristic** + Owner: `squad:flight` + Formal decision on when to use which strategy. Default: checkout-b for solo work, worktree for parallel. Write to decisions.md. + +## Priority Relative to Backlog + +**Above:** Long-term/exploratory (#357, #316, #308, #296, #260, #252), manual verification debt (#418–#421) +**Comparable to:** #457 (monorepo context), #413 (knowledge library) — all infrastructure improvements +**Below:** Wave 1 (#508, #330/#354), PRDs (#498, #485, #481), GitLab support (#465) + +**Recommendation:** Sub-issue #1 (doc fix) is a quick win — ship immediately. Sub-issues #2–#5 go into the post-Wave-1 queue, likely alongside SubSquads work where parallel execution becomes a hard requirement. + +## Top 5 Priority Recommendations for v1 Progress + +1. **#508 — Ambient Personal Squad** — Wave 1 in progress, highest user-facing value +2. **#498 — Remove .squad/ from version control** — Critical for v1 GA; repos shouldn't ship team state +3. **#485 — Agent Spec & Validation Framework** — Foundation for quality gates and onboarding +4. **#481 — Typed StorageProvider Interface** — SDK maturity; unblocks #498 +5. **#347 — Shore up squad init --sdk** — Onboarding gate; first impression for SDK users + +**Quick wins:** #525 sub-issue #1 (doc fix), #347 (scoped CLI work). +**Deprioritize:** Manual verification issues (#418–#421) are test debt, not v1-blocking. Long-term exploratory items (#357, #316, #308, #296, #260, #252) stay backlog. +**Shelved (unchanged):** A2A suite (#332–#336) per existing team decision. + +--- + +# Proposal: Economy Mode Integration in squad.agent.md + +**By:** Procedures (Prompt Engineer) +**Date:** 2026-03-22 +**Issues:** #500 +**Status:** DRAFT — for Flight review before merging to squad.agent.md + +--- + +## Summary + +Economy mode is a new session/persistent modifier that shifts Layer 3 (Task-Aware Auto-Selection) to cost-optimized alternatives. This proposal documents the governance additions needed in `squad.agent.md`. + +**Note to Flight:** Procedures owns the skill design. Squad.agent.md is governance — Flight reviews before commit. + +--- + +## 1. New Paragraph After Layer 0 (Per-Agent Model Selection section) + +Insert after the existing Layer 0 bullet points and before "**Layer 1 — Session Directive**": + +--- + +**Economy Mode — Cost Modifier (Layer 3 override):** Economy mode shifts all Layer 3 auto-selection to cost-optimized alternatives. It does NOT override Layer 0 (persistent config), Layer 1 (explicit session directive), or Layer 2 (charter preference) — user intent always wins. + +- **Activation (session):** User says "use economy mode", "save costs", "go cheap" → activate for this session only. +- **Activation (persistent):** User says "always use economy mode" OR `"economyMode": true` in `.squad/config.json` → persists across sessions. +- **Deactivation:** "turn off economy mode" or remove `economyMode` from `config.json`. +- **On session start:** Read `.squad/config.json`. If `economyMode: true`, activate economy mode before any spawns. + +--- + +## 2. Economy Model Selection Table + +Add after Layer 3 normal table: + +--- + +**Economy Mode Layer 3 Table** (active when economy mode is on): + +| Task Output | Normal Mode | Economy Mode | +|-------------|-------------|--------------| +| Writing code (implementation, refactoring, bug fixes) | `claude-sonnet-4.5` | `gpt-4.1` or `gpt-5-mini` | +| Writing prompts or agent designs | `claude-sonnet-4.5` | `gpt-4.1` or `gpt-5-mini` | +| Docs, planning, triage, changelogs, mechanical ops | `claude-haiku-4.5` | `gpt-4.1` or `gpt-5-mini` | +| Architecture, code review, security audits | `claude-opus-4.5` | `claude-sonnet-4.5` | +| Scribe / logger / mechanical file ops | `claude-haiku-4.5` | `gpt-4.1` | + +Prefer `gpt-4.1` over `gpt-5-mini` for structured output or tool use. Prefer `gpt-5-mini` for pure text generation. + +--- + +## 3. Spawn Acknowledgment Convention + +Add to the spawn acknowledgment format guidance: + +--- + +When economy mode is active, include `💰 economy` after the model name in spawn acknowledgments: + +``` +🔧 Fenster (gpt-4.1 · 💰 economy) — fixing auth bug +📋 Scribe (gpt-4.1 · 💰 economy) — logging decision +``` + +This gives the user instant visibility that cost-optimized models are in use. + +--- + +## 4. Valid Models Catalog Audit + +Current "Valid models" section lists: + +``` +Premium: claude-opus-4.6, claude-opus-4.6-fast, claude-opus-4.5 +Standard: claude-sonnet-4.5, claude-sonnet-4, gpt-5.2-codex, gpt-5.2, gpt-5.1-codex-max, gpt-5.1-codex, gpt-5.1, gpt-5, gemini-3-pro-preview +Fast/Cheap: claude-haiku-4.5, gpt-5.1-codex-mini, gpt-5-mini, gpt-4.1 +``` + +**Audit findings:** +- `claude-opus-4.6` and `claude-opus-4.6-fast` are listed but not used in the Layer 3 table (table uses `claude-opus-4.5`). The Layer 3 table should reference `claude-opus-4.6` as the premium default for consistency with the catalog. +- `claude-sonnet-4.6` appears in the model-selection SKILL.md but is absent from the valid models list in squad.agent.md. Add it under Standard. +- Economy mode introduces `gpt-4.1` and `gpt-5-mini` as primary alternatives — both are already in the Fast/Cheap catalog. No additions needed. + +**Proposed updated catalog:** + +``` +Premium: claude-opus-4.6, claude-opus-4.6-fast, claude-opus-4.5 +Standard: claude-sonnet-4.6, claude-sonnet-4.5, claude-sonnet-4, gpt-5.4, gpt-5.3-codex, gpt-5.2-codex, gpt-5.2, gpt-5.1-codex-max, gpt-5.1-codex, gpt-5.1, gpt-5, gemini-3-pro-preview +Fast/Cheap: claude-haiku-4.5, gpt-5.1-codex-mini, gpt-5-mini, gpt-4.1 +``` + +(Added `claude-sonnet-4.6`, `gpt-5.4`, `gpt-5.3-codex` which appear in the model-selection SKILL.md fallback chains but are missing from squad.agent.md's catalog.) + +--- + +## 5. Config Schema Addition + +Add `economyMode` to the config schema reference in squad.agent.md (wherever `defaultModel` is documented): + +```json +{ + "version": 1, + "defaultModel": "claude-sonnet-4.6", + "economyMode": true, + "agentModelOverrides": { + "fenster": "claude-sonnet-4.6" + } +} +``` + +--- + +## Rationale + +Economy mode solves a real user need: "I want all agents to run cheaper, but I don't want to set each one individually." It's a session-level modifier that works orthogonally to the existing hierarchy — no layer gets changed, only Layer 3's lookup table swaps. The `💰` indicator keeps it transparent. + +The skill (`economy-mode/SKILL.md`) covers the coordinator behavior in detail. This proposal is the governance side — ensuring squad.agent.md is the authoritative source for the feature. + +--- + +## References + +- Skill: `.squad/skills/economy-mode/SKILL.md` +- Issue: #500 +- Model selection skill: `.squad/skills/model-selection/SKILL.md` + +--- + +# Proposal: Personal Squad Governance Awareness in squad.agent.md + +**By:** Procedures (Prompt Engineer) +**Date:** 2026-03-22 +**Issues:** #344 +**Status:** DRAFT — for Flight review before merging to squad.agent.md + +--- + +## Summary + +Squad has a consult mode (implemented, per `prd-consult-mode.md`) and personal squad semantics (via `resolveGlobalSquadPath()`), but `squad.agent.md` doesn't tell the coordinator how to reason about either. This proposal documents the gaps and the governance additions needed. + +--- + +## Gap Analysis + +### Gap 1: Init Mode references `--global` without explaining personal squad resolution + +Current Init Mode says "run `squad init --global` for a personal squad" (implied by CLI docs) but squad.agent.md doesn't explain what a personal squad IS or how the coordinator should detect it. + +**What agents need to know:** +- Personal squad = a squad at the global path (resolved via `resolveGlobalSquadPath()`) + - Linux/macOS: `~/.config/squad/.squad` + - macOS (alt): `~/Library/Application Support/squad/.squad` + - Windows: `%APPDATA%\squad\.squad` +- If `.squad/config.json` contains `"consult": true`, the coordinator is working inside a consult session +- `sourceSquad` in `config.json` points to the original personal squad (for Scribe extraction context) + +### Gap 2: No coordinator guidance for consult mode + +`squad.agent.md` mentions nothing about consult mode. The coordinator doesn't know: +- How to recognize it's in a consult session +- That writes go to the project `.squad/` (isolated copy) — NOT the personal squad +- That Scribe's charter is patched with extraction instructions +- That `.squad/extract/` is a staging area for generic learnings + +### Gap 3: TEAM_ROOT works, but personal squad semantics are absent + +The coordinator resolves `TEAM_ROOT` correctly (Worktree Awareness section), but: +- No distinction between "project squad" vs "personal squad copy in consult mode" +- No guidance on what to tell agents about their squad context when in consult mode + +### Gap 4: Charter templates have no personal-squad-aware patterns + +Agent charters have no concept of: +- Consult mode restrictions (agents shouldn't commit to project, shouldn't pollute personal squad) +- Extraction tagging (Scribe needs to flag decisions as generic vs project-specific) + +### Gap 5: No skill for consult-mode behavior + +There is no skill for consult-mode coordinator behavior, even though consult mode has distinct patterns (invisibility, extraction, isolation). + +--- + +## Proposed squad.agent.md Additions + +### Addition 1: Consult Mode Detection (in Team Mode → On Session Start) + +After "resolve the team root" and before Issue Awareness, add: + +--- + +**Consult Mode Detection:** After resolving team root, check `.squad/config.json` for `"consult": true`. + +- If `consult: true` → **Consult mode is active.** This is a personal squad consulting on a project. + - The `.squad/` directory is an isolated copy of the user's personal squad. + - `sourceSquad` in `config.json` contains the path to the original personal squad. + - Do NOT read or write to `sourceSquad` — it's out of scope. Only operate within TEAM_ROOT. + - Scribe's charter is already patched with extraction instructions — no coordinator action needed. + - Include `🧳 consult` in your session acknowledgment: `Squad v{version} (🧳 consult — {projectName})` + - Remind agents: decisions they make here are project-isolated until explicitly extracted. +- If `consult: false` or absent → Normal mode. Team root is authoritative. + +--- + +### Addition 2: Personal Squad Path Reference + +Add a new subsection under "Worktree Awareness": + +--- + +**Personal Squad Paths:** The global squad path is resolved by `resolveGlobalSquadPath()`: + +| Platform | Path | +|----------|------| +| Linux | `~/.config/squad/.squad` | +| macOS | `~/Library/Application Support/squad/.squad` | +| Windows | `%APPDATA%\squad\.squad` | + +The coordinator should NEVER hard-code these paths. Use `squad --global` or `resolveGlobalSquadPath()` to resolve. Only relevant in consult mode (to understand the `sourceSquad` field) — the coordinator does NOT read the personal squad directly during a session. + +--- + +### Addition 3: Consult Mode Spawn Guidance + +Add to the spawn template section: + +--- + +**In consult mode:** Pass `CONSULT_MODE: true` and `PROJECT_NAME: {projectName}` in spawn prompts alongside `TEAM_ROOT`. This lets agents know: +1. Their decisions will be reviewed for extraction — keep project-specific and generic reasoning separate +2. They should NOT reference personal squad paths or personal squad agent names +3. Scribe will classify their decisions — agents should write clear, extractable decision rationale + +--- + +### Addition 4: Consult Mode Acknowledgment Format + +Add to spawn acknowledgment conventions: + +``` +🧳 consult mode active — Fenster (claude-sonnet-4.5) — refactoring auth module + ↳ decisions staged in .squad/extract/ for review before extraction +``` + +--- + +## Proposed New Skill + +**Skill needed:** `.squad/skills/consult-mode/SKILL.md` + +Should cover: +- Detecting consult mode from config.json +- Coordinator behavior changes (CONSULT_MODE in spawn prompts) +- Scribe extraction workflow (already documented in prd-consult-mode.md — condense into skill) +- Acknowledgment format conventions +- STOP: extraction is always user-driven via `squad extract` — coordinator never auto-extracts + +This skill should be authored after this governance proposal is approved, to avoid the skill getting ahead of the governance. + +--- + +## Charter Template Additions + +All agent charter templates should include a note in "How I Work": + +```markdown +**Consult Mode Awareness:** If `CONSULT_MODE: true` is in my spawn prompt, I'm working on a project outside my home squad. My decisions here are project-isolated. Write extractable rationale so Scribe can classify them for `squad extract` review. +``` + +This should be added to `.squad/templates/charter.md` (if it exists) and `.squad/agents/scribe/charter.md` (Scribe already has extraction logic, but clarifying the classification responsibility is valuable). + +--- + +## Rationale + +Consult mode is fully implemented at the SDK level (`prd-consult-mode.md`, `squad consult` command) but the coordinator has no awareness of it. The result: agents running in a consult session have no context that they're in a temporary, isolated copy of a personal squad. They might make decisions as if they're permanent, or reference the project in ways that pollute the personal squad on extraction. + +These governance additions close the loop between the implementation (CLI + SDK) and the runtime behavior (coordinator + agents). + +--- + +## References + +- Consult mode PRD: `.squad/identity/prd-consult-mode.md` +- Issue: #344 +- Flight ambient personal squad note: `.squad/decisions/inbox/flight-ambient-personal-squad.md` + diff --git a/.squad/decisions/inbox/eecom-economy-mode.md b/.squad/decisions/inbox/eecom-economy-mode.md deleted file mode 100644 index d534aa5d64..0000000000 --- a/.squad/decisions/inbox/eecom-economy-mode.md +++ /dev/null @@ -1,37 +0,0 @@ -# Economy Mode Design — #500 - -**Date:** 2026-03-20 -**Author:** EECOM -**Issue:** #500 - -## Decision - -Economy mode is implemented as a modifier that shifts model selection at Layer 3 (task-aware auto) and Layer 4 (default fallback) only. Layers 0–2 (explicit user preferences) are never downgraded. - -## Model Map - -| Normal | Economy | Use case | -|--------|---------|----------| -| `claude-opus-4.6` | `claude-sonnet-4.5` | Architecture, review | -| `claude-sonnet-4.6` | `gpt-4.1` | Code writing | -| `claude-sonnet-4.5` | `gpt-4.1` | Code writing | -| `claude-haiku-4.5` | `gpt-4.1` | Docs, planning, mechanical | - -## Activation - -1. **Persistent:** `"economyMode": true` in `.squad/config.json` (survives sessions) -2. **Session:** `--economy` CLI flag (sets `SQUAD_ECONOMY_MODE=1` env var, current session only) -3. **Toggle command:** `squad economy on|off` writes to config.json - -## Hierarchy Integration - -Economy mode is a Layer 3/4 modifier — it does NOT override explicit preferences (Layers 0–2). This is intentional: if a user said "always use opus", economy mode respects that choice. - -## Implementation Points - -- `ECONOMY_MODEL_MAP` + `applyEconomyMode()` in `packages/squad-sdk/src/config/models.ts` -- `readEconomyMode()` + `writeEconomyMode()` in `packages/squad-sdk/src/config/models.ts` -- `resolveModel()` in `config/models.ts` accepts `economyMode?: boolean` option; reads from config if not provided -- `resolveModel()` in `agents/model-selector.ts` also supports `economyMode?: boolean` -- `squad economy [on|off]` command in `packages/squad-cli/src/cli/commands/economy.ts` -- `--economy` global flag in `cli-entry.ts` diff --git a/.squad/decisions/inbox/procedures-economy-mode-governance.md b/.squad/decisions/inbox/procedures-economy-mode-governance.md deleted file mode 100644 index 4561be9409..0000000000 --- a/.squad/decisions/inbox/procedures-economy-mode-governance.md +++ /dev/null @@ -1,126 +0,0 @@ -# Proposal: Economy Mode Integration in squad.agent.md - -**By:** Procedures (Prompt Engineer) -**Date:** 2026-03-22 -**Issues:** #500 -**Status:** DRAFT — for Flight review before merging to squad.agent.md - ---- - -## Summary - -Economy mode is a new session/persistent modifier that shifts Layer 3 (Task-Aware Auto-Selection) to cost-optimized alternatives. This proposal documents the governance additions needed in `squad.agent.md`. - -**Note to Flight:** Procedures owns the skill design. Squad.agent.md is governance — Flight reviews before commit. - ---- - -## 1. New Paragraph After Layer 0 (Per-Agent Model Selection section) - -Insert after the existing Layer 0 bullet points and before "**Layer 1 — Session Directive**": - ---- - -**Economy Mode — Cost Modifier (Layer 3 override):** Economy mode shifts all Layer 3 auto-selection to cost-optimized alternatives. It does NOT override Layer 0 (persistent config), Layer 1 (explicit session directive), or Layer 2 (charter preference) — user intent always wins. - -- **Activation (session):** User says "use economy mode", "save costs", "go cheap" → activate for this session only. -- **Activation (persistent):** User says "always use economy mode" OR `"economyMode": true` in `.squad/config.json` → persists across sessions. -- **Deactivation:** "turn off economy mode" or remove `economyMode` from `config.json`. -- **On session start:** Read `.squad/config.json`. If `economyMode: true`, activate economy mode before any spawns. - ---- - -## 2. Economy Model Selection Table - -Add after Layer 3 normal table: - ---- - -**Economy Mode Layer 3 Table** (active when economy mode is on): - -| Task Output | Normal Mode | Economy Mode | -|-------------|-------------|--------------| -| Writing code (implementation, refactoring, bug fixes) | `claude-sonnet-4.5` | `gpt-4.1` or `gpt-5-mini` | -| Writing prompts or agent designs | `claude-sonnet-4.5` | `gpt-4.1` or `gpt-5-mini` | -| Docs, planning, triage, changelogs, mechanical ops | `claude-haiku-4.5` | `gpt-4.1` or `gpt-5-mini` | -| Architecture, code review, security audits | `claude-opus-4.5` | `claude-sonnet-4.5` | -| Scribe / logger / mechanical file ops | `claude-haiku-4.5` | `gpt-4.1` | - -Prefer `gpt-4.1` over `gpt-5-mini` for structured output or tool use. Prefer `gpt-5-mini` for pure text generation. - ---- - -## 3. Spawn Acknowledgment Convention - -Add to the spawn acknowledgment format guidance: - ---- - -When economy mode is active, include `💰 economy` after the model name in spawn acknowledgments: - -``` -🔧 Fenster (gpt-4.1 · 💰 economy) — fixing auth bug -📋 Scribe (gpt-4.1 · 💰 economy) — logging decision -``` - -This gives the user instant visibility that cost-optimized models are in use. - ---- - -## 4. Valid Models Catalog Audit - -Current "Valid models" section lists: - -``` -Premium: claude-opus-4.6, claude-opus-4.6-fast, claude-opus-4.5 -Standard: claude-sonnet-4.5, claude-sonnet-4, gpt-5.2-codex, gpt-5.2, gpt-5.1-codex-max, gpt-5.1-codex, gpt-5.1, gpt-5, gemini-3-pro-preview -Fast/Cheap: claude-haiku-4.5, gpt-5.1-codex-mini, gpt-5-mini, gpt-4.1 -``` - -**Audit findings:** -- `claude-opus-4.6` and `claude-opus-4.6-fast` are listed but not used in the Layer 3 table (table uses `claude-opus-4.5`). The Layer 3 table should reference `claude-opus-4.6` as the premium default for consistency with the catalog. -- `claude-sonnet-4.6` appears in the model-selection SKILL.md but is absent from the valid models list in squad.agent.md. Add it under Standard. -- Economy mode introduces `gpt-4.1` and `gpt-5-mini` as primary alternatives — both are already in the Fast/Cheap catalog. No additions needed. - -**Proposed updated catalog:** - -``` -Premium: claude-opus-4.6, claude-opus-4.6-fast, claude-opus-4.5 -Standard: claude-sonnet-4.6, claude-sonnet-4.5, claude-sonnet-4, gpt-5.4, gpt-5.3-codex, gpt-5.2-codex, gpt-5.2, gpt-5.1-codex-max, gpt-5.1-codex, gpt-5.1, gpt-5, gemini-3-pro-preview -Fast/Cheap: claude-haiku-4.5, gpt-5.1-codex-mini, gpt-5-mini, gpt-4.1 -``` - -(Added `claude-sonnet-4.6`, `gpt-5.4`, `gpt-5.3-codex` which appear in the model-selection SKILL.md fallback chains but are missing from squad.agent.md's catalog.) - ---- - -## 5. Config Schema Addition - -Add `economyMode` to the config schema reference in squad.agent.md (wherever `defaultModel` is documented): - -```json -{ - "version": 1, - "defaultModel": "claude-sonnet-4.6", - "economyMode": true, - "agentModelOverrides": { - "fenster": "claude-sonnet-4.6" - } -} -``` - ---- - -## Rationale - -Economy mode solves a real user need: "I want all agents to run cheaper, but I don't want to set each one individually." It's a session-level modifier that works orthogonally to the existing hierarchy — no layer gets changed, only Layer 3's lookup table swaps. The `💰` indicator keeps it transparent. - -The skill (`economy-mode/SKILL.md`) covers the coordinator behavior in detail. This proposal is the governance side — ensuring squad.agent.md is the authoritative source for the feature. - ---- - -## References - -- Skill: `.squad/skills/economy-mode/SKILL.md` -- Issue: #500 -- Model selection skill: `.squad/skills/model-selection/SKILL.md` diff --git a/.squad/decisions/inbox/procedures-personal-squad-governance.md b/.squad/decisions/inbox/procedures-personal-squad-governance.md deleted file mode 100644 index b46bb17dec..0000000000 --- a/.squad/decisions/inbox/procedures-personal-squad-governance.md +++ /dev/null @@ -1,158 +0,0 @@ -# Proposal: Personal Squad Governance Awareness in squad.agent.md - -**By:** Procedures (Prompt Engineer) -**Date:** 2026-03-22 -**Issues:** #344 -**Status:** DRAFT — for Flight review before merging to squad.agent.md - ---- - -## Summary - -Squad has a consult mode (implemented, per `prd-consult-mode.md`) and personal squad semantics (via `resolveGlobalSquadPath()`), but `squad.agent.md` doesn't tell the coordinator how to reason about either. This proposal documents the gaps and the governance additions needed. - ---- - -## Gap Analysis - -### Gap 1: Init Mode references `--global` without explaining personal squad resolution - -Current Init Mode says "run `squad init --global` for a personal squad" (implied by CLI docs) but squad.agent.md doesn't explain what a personal squad IS or how the coordinator should detect it. - -**What agents need to know:** -- Personal squad = a squad at the global path (resolved via `resolveGlobalSquadPath()`) - - Linux/macOS: `~/.config/squad/.squad` - - macOS (alt): `~/Library/Application Support/squad/.squad` - - Windows: `%APPDATA%\squad\.squad` -- If `.squad/config.json` contains `"consult": true`, the coordinator is working inside a consult session -- `sourceSquad` in `config.json` points to the original personal squad (for Scribe extraction context) - -### Gap 2: No coordinator guidance for consult mode - -`squad.agent.md` mentions nothing about consult mode. The coordinator doesn't know: -- How to recognize it's in a consult session -- That writes go to the project `.squad/` (isolated copy) — NOT the personal squad -- That Scribe's charter is patched with extraction instructions -- That `.squad/extract/` is a staging area for generic learnings - -### Gap 3: TEAM_ROOT works, but personal squad semantics are absent - -The coordinator resolves `TEAM_ROOT` correctly (Worktree Awareness section), but: -- No distinction between "project squad" vs "personal squad copy in consult mode" -- No guidance on what to tell agents about their squad context when in consult mode - -### Gap 4: Charter templates have no personal-squad-aware patterns - -Agent charters have no concept of: -- Consult mode restrictions (agents shouldn't commit to project, shouldn't pollute personal squad) -- Extraction tagging (Scribe needs to flag decisions as generic vs project-specific) - -### Gap 5: No skill for consult-mode behavior - -There is no skill for consult-mode coordinator behavior, even though consult mode has distinct patterns (invisibility, extraction, isolation). - ---- - -## Proposed squad.agent.md Additions - -### Addition 1: Consult Mode Detection (in Team Mode → On Session Start) - -After "resolve the team root" and before Issue Awareness, add: - ---- - -**Consult Mode Detection:** After resolving team root, check `.squad/config.json` for `"consult": true`. - -- If `consult: true` → **Consult mode is active.** This is a personal squad consulting on a project. - - The `.squad/` directory is an isolated copy of the user's personal squad. - - `sourceSquad` in `config.json` contains the path to the original personal squad. - - Do NOT read or write to `sourceSquad` — it's out of scope. Only operate within TEAM_ROOT. - - Scribe's charter is already patched with extraction instructions — no coordinator action needed. - - Include `🧳 consult` in your session acknowledgment: `Squad v{version} (🧳 consult — {projectName})` - - Remind agents: decisions they make here are project-isolated until explicitly extracted. -- If `consult: false` or absent → Normal mode. Team root is authoritative. - ---- - -### Addition 2: Personal Squad Path Reference - -Add a new subsection under "Worktree Awareness": - ---- - -**Personal Squad Paths:** The global squad path is resolved by `resolveGlobalSquadPath()`: - -| Platform | Path | -|----------|------| -| Linux | `~/.config/squad/.squad` | -| macOS | `~/Library/Application Support/squad/.squad` | -| Windows | `%APPDATA%\squad\.squad` | - -The coordinator should NEVER hard-code these paths. Use `squad --global` or `resolveGlobalSquadPath()` to resolve. Only relevant in consult mode (to understand the `sourceSquad` field) — the coordinator does NOT read the personal squad directly during a session. - ---- - -### Addition 3: Consult Mode Spawn Guidance - -Add to the spawn template section: - ---- - -**In consult mode:** Pass `CONSULT_MODE: true` and `PROJECT_NAME: {projectName}` in spawn prompts alongside `TEAM_ROOT`. This lets agents know: -1. Their decisions will be reviewed for extraction — keep project-specific and generic reasoning separate -2. They should NOT reference personal squad paths or personal squad agent names -3. Scribe will classify their decisions — agents should write clear, extractable decision rationale - ---- - -### Addition 4: Consult Mode Acknowledgment Format - -Add to spawn acknowledgment conventions: - -``` -🧳 consult mode active — Fenster (claude-sonnet-4.5) — refactoring auth module - ↳ decisions staged in .squad/extract/ for review before extraction -``` - ---- - -## Proposed New Skill - -**Skill needed:** `.squad/skills/consult-mode/SKILL.md` - -Should cover: -- Detecting consult mode from config.json -- Coordinator behavior changes (CONSULT_MODE in spawn prompts) -- Scribe extraction workflow (already documented in prd-consult-mode.md — condense into skill) -- Acknowledgment format conventions -- STOP: extraction is always user-driven via `squad extract` — coordinator never auto-extracts - -This skill should be authored after this governance proposal is approved, to avoid the skill getting ahead of the governance. - ---- - -## Charter Template Additions - -All agent charter templates should include a note in "How I Work": - -```markdown -**Consult Mode Awareness:** If `CONSULT_MODE: true` is in my spawn prompt, I'm working on a project outside my home squad. My decisions here are project-isolated. Write extractable rationale so Scribe can classify them for `squad extract` review. -``` - -This should be added to `.squad/templates/charter.md` (if it exists) and `.squad/agents/scribe/charter.md` (Scribe already has extraction logic, but clarifying the classification responsibility is valuable). - ---- - -## Rationale - -Consult mode is fully implemented at the SDK level (`prd-consult-mode.md`, `squad consult` command) but the coordinator has no awareness of it. The result: agents running in a consult session have no context that they're in a temporary, isolated copy of a personal squad. They might make decisions as if they're permanent, or reference the project in ways that pollute the personal squad on extraction. - -These governance additions close the loop between the implementation (CLI + SDK) and the runtime behavior (coordinator + agents). - ---- - -## References - -- Consult mode PRD: `.squad/identity/prd-consult-mode.md` -- Issue: #344 -- Flight ambient personal squad note: `.squad/decisions/inbox/flight-ambient-personal-squad.md` From c4b383da6832a507e3ad1cc0c92e78d020fca562 Mon Sep 17 00:00:00 2001 From: bradygaster Date: Sun, 22 Mar 2026 16:12:03 -0700 Subject: [PATCH 2/3] chore(.squad): merge decisions, log #508/#525 fan-out --- .squad/decisions.md | 3 +++ .squad/orchestration-log/2026-03-22T23-10.md | 1 + 2 files changed, 4 insertions(+) create mode 100644 .squad/orchestration-log/2026-03-22T23-10.md diff --git a/.squad/decisions.md b/.squad/decisions.md index 0e04695469..21dc404e09 100644 --- a/.squad/decisions.md +++ b/.squad/decisions.md @@ -6624,3 +6624,6 @@ These governance additions close the loop between the implementation (CLI + SDK) - Issue: #344 - Flight ambient personal squad note: `.squad/decisions/inbox/flight-ambient-personal-squad.md` + + + diff --git a/.squad/orchestration-log/2026-03-22T23-10.md b/.squad/orchestration-log/2026-03-22T23-10.md new file mode 100644 index 0000000000..441c5f9d7c --- /dev/null +++ b/.squad/orchestration-log/2026-03-22T23-10.md @@ -0,0 +1 @@ +2026-03-22T23:10Z: Coordinator enabled worktrees, launched #508 fan-out (EECOM SDK + Procedures Governance), Flight authoring #525 sub-issues. Priority restack: #508 first, #525 sub-items replace #347, #498 parallel, hold #481/#347. From f52460d55316000796f4c41d1da130e339b9fc2a Mon Sep 17 00:00:00 2001 From: bradygaster Date: Sun, 22 Mar 2026 16:56:59 -0700 Subject: [PATCH 3/3] feat(sdk): implement post-merge worktree cleanup lifecycle (#530) - Add listWorktrees() to parse git worktree state - Add findMergedWorktrees() to detect cleanup candidates - Add cleanupWorktree() with force and dry-run options - Add cleanupMergedWorktrees() for batch cleanup (Ralph heartbeat) - Handle edge cases: dirty, locked, missing, main worktree - Add comprehensive tests with real git repos Closes #530 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-sdk/src/platform/index.ts | 2 + .../src/platform/worktree-lifecycle.ts | 319 +++++++++++++ test/worktree-lifecycle.test.ts | 427 ++++++++++++++++++ 3 files changed, 748 insertions(+) create mode 100644 packages/squad-sdk/src/platform/worktree-lifecycle.ts create mode 100644 test/worktree-lifecycle.test.ts diff --git a/packages/squad-sdk/src/platform/index.ts b/packages/squad-sdk/src/platform/index.ts index 479a0f473b..065e1ae941 100644 --- a/packages/squad-sdk/src/platform/index.ts +++ b/packages/squad-sdk/src/platform/index.ts @@ -18,6 +18,8 @@ export { FileLogCommunicationAdapter } from './comms-file-log.js'; export { GitHubDiscussionsCommunicationAdapter } from './comms-github-discussions.js'; export { ADODiscussionCommunicationAdapter } from './comms-ado-discussions.js'; export { createCommunicationAdapter } from './comms.js'; +export { listWorktrees, findMergedWorktrees, cleanupWorktree, cleanupMergedWorktrees } from './worktree-lifecycle.js'; +export type { WorktreeInfo, CleanupResult, BatchCleanupResult } from './worktree-lifecycle.js'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; diff --git a/packages/squad-sdk/src/platform/worktree-lifecycle.ts b/packages/squad-sdk/src/platform/worktree-lifecycle.ts new file mode 100644 index 0000000000..ac9ab7b831 --- /dev/null +++ b/packages/squad-sdk/src/platform/worktree-lifecycle.ts @@ -0,0 +1,319 @@ +/** + * Worktree lifecycle management for Squad — detect merged branches and clean up. + * + * @module platform/worktree-lifecycle + */ + +import { execSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; + +export interface WorktreeInfo { + path: string; + branch: string; + head: string; + /** Whether the worktree's branch has been merged into the base branch */ + isMerged: boolean; + /** Whether the worktree has uncommitted changes */ + isDirty: boolean; + /** Whether the worktree is the main worktree (repo root) */ + isMain: boolean; + /** Whether the worktree is locked */ + isLocked: boolean; +} + +export interface CleanupResult { + success: boolean; + actions: string[]; +} + +export interface BatchCleanupResult { + cleaned: number; + skipped: number; + actions: string[]; +} + +/** + * Parse `git worktree list --porcelain` output into structured data. + */ +function parseWorktreeListOutput(output: string, repoRoot: string, baseBranch: string): WorktreeInfo[] { + const worktrees: WorktreeInfo[] = []; + const lines = output.split('\n').map(l => l.trim()).filter(Boolean); + + let current: Partial = {}; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + // Start of a new worktree entry + if (current.path) { + worktrees.push(finalizeWorktreeInfo(current, repoRoot, baseBranch)); + } + current = { path: line.substring('worktree '.length) }; + } else if (line.startsWith('HEAD ')) { + current.head = line.substring('HEAD '.length); + } else if (line.startsWith('branch ')) { + const fullBranch = line.substring('branch '.length); + // refs/heads/feature-branch → feature-branch + current.branch = fullBranch.replace(/^refs\/heads\//, ''); + } else if (line === 'bare') { + // Bare worktrees are never cleaned up + current.isMain = true; + } else if (line.startsWith('locked')) { + current.isLocked = true; + } else if (line === 'detached') { + // Detached HEAD worktrees — treat as non-main + current.branch = current.branch || 'HEAD'; + } + } + + // Add last entry + if (current.path) { + worktrees.push(finalizeWorktreeInfo(current, repoRoot, baseBranch)); + } + + return worktrees; +} + +/** + * Finalize a worktree info by checking merge status and dirty state. + */ +function finalizeWorktreeInfo( + partial: Partial, + repoRoot: string, + baseBranch: string +): WorktreeInfo { + const worktreePath = partial.path || ''; + const branch = partial.branch || 'HEAD'; + const head = partial.head || ''; + + // Determine if this is the main worktree + const isMain = partial.isMain === true || path.normalize(worktreePath) === path.normalize(repoRoot); + + // Check if branch is merged into base branch + let isMerged = false; + if (!isMain && branch !== 'HEAD') { + try { + const mergedBranches = execSync(`git branch --merged ${baseBranch}`, { + cwd: repoRoot, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10_000, + }).trim(); + + // Check if our branch appears in the merged list + // Lines look like: " branch-name" or "* branch-name" (current branch) or "+ branch-name" (worktree branch) + const branches = mergedBranches.split('\n').map(line => line.replace(/^[*+]?\s+/, '').trim()); + isMerged = branches.includes(branch); + } catch { + // If we can't check, assume not merged + isMerged = false; + } + } + + // Check if worktree has uncommitted changes + let isDirty = false; + if (!isMain && fs.existsSync(worktreePath)) { + try { + const status = execSync('git status --porcelain', { + cwd: worktreePath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10_000, + }).trim(); + isDirty = status.length > 0; + } catch { + // If we can't check, assume dirty to be safe + isDirty = true; + } + } + + return { + path: worktreePath, + branch, + head, + isMerged, + isDirty, + isMain, + isLocked: partial.isLocked === true, + }; +} + +/** + * List all git worktrees for a repo with merge and dirty status. + */ +export function listWorktrees(repoRoot: string, baseBranch: string = 'dev'): WorktreeInfo[] { + try { + const output = execSync('git worktree list --porcelain', { + cwd: repoRoot, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10_000, + }); + + return parseWorktreeListOutput(output, repoRoot, baseBranch); + } catch (error) { + // If git worktree list fails, return empty array + return []; + } +} + +/** + * Find worktrees whose branches have been merged and are safe to remove. + * Excludes dirty worktrees, locked worktrees, and the main worktree. + */ +export function findMergedWorktrees( + repoRoot: string, + baseBranch: string = 'dev' +): WorktreeInfo[] { + const all = listWorktrees(repoRoot, baseBranch); + return all.filter(wt => + wt.isMerged && + !wt.isDirty && + !wt.isMain && + !wt.isLocked + ); +} + +/** + * Clean up a single worktree: remove worktree, delete branch, prune stale references. + * Returns true if cleanup succeeded. + */ +export function cleanupWorktree( + repoRoot: string, + worktree: WorktreeInfo, + options?: { force?: boolean; dryRun?: boolean } +): CleanupResult { + const actions: string[] = []; + + // Safety check: never clean up main worktree + if (worktree.isMain) { + actions.push(`⚠️ Skipped main worktree: ${worktree.path}`); + return { success: false, actions }; + } + + // Check if locked + if (worktree.isLocked && !options?.force) { + actions.push(`⚠️ Skipped locked worktree: ${worktree.path}`); + return { success: false, actions }; + } + + // Check if dirty + if (worktree.isDirty && !options?.force) { + actions.push(`⚠️ Skipped dirty worktree: ${worktree.path} (use --force to clean up anyway)`); + return { success: false, actions }; + } + + if (options?.dryRun) { + actions.push(`[dry-run] Would remove worktree: ${worktree.path}`); + if (worktree.branch !== 'HEAD') { + actions.push(`[dry-run] Would delete branch: ${worktree.branch}`); + } + actions.push(`[dry-run] Would prune stale worktree references`); + return { success: true, actions }; + } + + try { + // 1. Remove worktree + const removeArgs = options?.force ? '--force' : ''; + const worktreeExists = fs.existsSync(worktree.path); + + if (worktreeExists) { + execSync(`git worktree remove ${removeArgs} "${worktree.path}"`, { + cwd: repoRoot, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10_000, + }); + actions.push(`✓ Removed worktree: ${worktree.path}`); + } else { + // Worktree directory was manually deleted — prune will clean up + actions.push(`⚠️ Worktree directory not found: ${worktree.path} (will prune)`); + } + + // 2. Delete branch (if not detached HEAD) + if (worktree.branch !== 'HEAD') { + try { + const deleteFlag = options?.force ? '-D' : '-d'; + execSync(`git branch ${deleteFlag} "${worktree.branch}"`, { + cwd: repoRoot, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10_000, + }); + actions.push(`✓ Deleted branch: ${worktree.branch}`); + } catch (error) { + // Branch might already be deleted or not exist + actions.push(`⚠️ Could not delete branch: ${worktree.branch}`); + } + } + + // 3. Prune stale worktree references + execSync('git worktree prune', { + cwd: repoRoot, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10_000, + }); + actions.push(`✓ Pruned stale worktree references`); + + return { success: true, actions }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + actions.push(`❌ Failed to clean up worktree: ${errorMsg}`); + return { success: false, actions }; + } +} + +/** + * Clean up ALL merged worktrees. Suitable for Ralph heartbeat. + * Continues on individual failures to clean up as many as possible. + */ +export function cleanupMergedWorktrees( + repoRoot: string, + baseBranch: string = 'dev', + options?: { force?: boolean; dryRun?: boolean } +): BatchCleanupResult { + const merged = findMergedWorktrees(repoRoot, baseBranch); + const actions: string[] = []; + let cleaned = 0; + let skipped = 0; + + if (merged.length === 0) { + actions.push('No merged worktrees to clean up.'); + return { cleaned, skipped, actions }; + } + + actions.push(`Found ${merged.length} merged worktree(s) to clean up:`); + + for (const worktree of merged) { + const result = cleanupWorktree(repoRoot, worktree, options); + actions.push(...result.actions); + + if (result.success) { + cleaned++; + } else { + skipped++; + } + } + + actions.push(`\nSummary: ${cleaned} cleaned, ${skipped} skipped`); + + return { cleaned, skipped, actions }; +} + +/** + * Ralph heartbeat can call cleanupMergedWorktrees() periodically + * to remove worktrees whose PRs have been merged. + * + * Integration point: ralph-commands.ts heartbeat handler + * Frequency: once per heartbeat cycle (every 5 minutes) + * + * Example usage: + * ```typescript + * import { cleanupMergedWorktrees } from './worktree-lifecycle.js'; + * + * // In Ralph's heartbeat handler: + * const result = cleanupMergedWorktrees(repoRoot, 'dev', { dryRun: false }); + * console.log(result.actions.join('\n')); + * ``` + */ diff --git a/test/worktree-lifecycle.test.ts b/test/worktree-lifecycle.test.ts new file mode 100644 index 0000000000..394ac05c4e --- /dev/null +++ b/test/worktree-lifecycle.test.ts @@ -0,0 +1,427 @@ +/** + * Integration tests for worktree lifecycle management. + * + * Tests worktree detection, merge status checking, and cleanup operations. + * Uses real git repos in temp directories for full integration testing. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + listWorktrees, + findMergedWorktrees, + cleanupWorktree, + cleanupMergedWorktrees, + type WorktreeInfo, +} from '../packages/squad-sdk/src/platform/worktree-lifecycle.js'; + +/** + * Initialize a minimal git repo for testing. + */ +function initGitRepo(dir: string): void { + execSync('git init', { cwd: dir, stdio: 'ignore', timeout: 10_000 }); + execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'ignore', timeout: 10_000 }); + execSync('git config user.name "Test User"', { cwd: dir, stdio: 'ignore', timeout: 10_000 }); + + // Create initial commit on dev branch + fs.writeFileSync(path.join(dir, 'README.md'), '# Test Repo\n'); + execSync('git add .', { cwd: dir, stdio: 'ignore', timeout: 10_000 }); + execSync('git commit -m "Initial commit"', { cwd: dir, stdio: 'ignore', timeout: 10_000 }); + execSync('git branch -M dev', { cwd: dir, stdio: 'ignore', timeout: 10_000 }); +} + +/** + * Create a worktree at the specified path. + */ +function createWorktree(repoRoot: string, worktreePath: string, branchName: string): void { + execSync(`git worktree add "${worktreePath}" -b ${branchName}`, { + cwd: repoRoot, + stdio: 'ignore', + timeout: 10_000, + }); +} + +/** + * Make a commit in the specified directory. + */ +function makeCommit(dir: string, filename: string, content: string, message: string): void { + fs.writeFileSync(path.join(dir, filename), content); + execSync('git add .', { cwd: dir, stdio: 'ignore', timeout: 10_000 }); + execSync(`git commit -m "${message}"`, { cwd: dir, stdio: 'ignore', timeout: 10_000 }); +} + +/** + * Merge a branch into dev. + */ +function mergeBranch(repoRoot: string, branchName: string): void { + // First, ensure we're on dev branch + execSync('git checkout dev', { + cwd: repoRoot, + stdio: 'ignore', + timeout: 10_000, + }); + + // Then merge + execSync(`git merge --no-ff ${branchName} -m "Merge ${branchName}"`, { + cwd: repoRoot, + stdio: 'ignore', + timeout: 10_000, + }); +} + +describe('worktree lifecycle', () => { + let testRoot: string; + let repoRoot: string; + + beforeEach(() => { + // Create a unique temp directory for each test + testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-worktree-test-')); + repoRoot = path.join(testRoot, 'repo'); + fs.mkdirSync(repoRoot, { recursive: true }); + initGitRepo(repoRoot); + }); + + afterEach(() => { + // Clean up temp directory + if (testRoot && fs.existsSync(testRoot)) { + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }); + + describe('listWorktrees', () => { + it('should list main worktree for repo with no extra worktrees', () => { + const worktrees = listWorktrees(repoRoot, 'dev'); + + expect(worktrees).toHaveLength(1); + expect(worktrees[0].isMain).toBe(true); + expect(worktrees[0].branch).toBe('dev'); + expect(path.normalize(worktrees[0].path)).toBe(path.normalize(repoRoot)); + }); + + it('should detect multiple worktrees', () => { + const worktree1 = path.join(testRoot, 'worktree-1'); + const worktree2 = path.join(testRoot, 'worktree-2'); + + createWorktree(repoRoot, worktree1, 'feature-1'); + createWorktree(repoRoot, worktree2, 'feature-2'); + + const worktrees = listWorktrees(repoRoot, 'dev'); + + expect(worktrees).toHaveLength(3); // main + 2 worktrees + + const wt1 = worktrees.find(w => w.branch === 'feature-1'); + const wt2 = worktrees.find(w => w.branch === 'feature-2'); + + expect(wt1).toBeDefined(); + expect(wt2).toBeDefined(); + expect(wt1?.isMain).toBe(false); + expect(wt2?.isMain).toBe(false); + }); + + it('should detect merged branches', () => { + const worktreePath = path.join(testRoot, 'worktree-merged'); + createWorktree(repoRoot, worktreePath, 'feature-merged'); + + // Make a commit in the worktree + makeCommit(worktreePath, 'feature.txt', 'feature content', 'Add feature'); + + // Switch back to dev and merge + mergeBranch(repoRoot, 'feature-merged'); + + const worktrees = listWorktrees(repoRoot, 'dev'); + const merged = worktrees.find(w => w.branch === 'feature-merged'); + + expect(merged).toBeDefined(); + expect(merged?.isMerged).toBe(true); + }); + + it('should detect dirty worktrees', () => { + const worktreePath = path.join(testRoot, 'worktree-dirty'); + createWorktree(repoRoot, worktreePath, 'feature-dirty'); + + // Add uncommitted changes + fs.writeFileSync(path.join(worktreePath, 'dirty.txt'), 'uncommitted'); + + const worktrees = listWorktrees(repoRoot, 'dev'); + const dirty = worktrees.find(w => w.branch === 'feature-dirty'); + + expect(dirty).toBeDefined(); + expect(dirty?.isDirty).toBe(true); + }); + + it('should detect clean worktrees', () => { + const worktreePath = path.join(testRoot, 'worktree-clean'); + createWorktree(repoRoot, worktreePath, 'feature-clean'); + + const worktrees = listWorktrees(repoRoot, 'dev'); + const clean = worktrees.find(w => w.branch === 'feature-clean'); + + expect(clean).toBeDefined(); + expect(clean?.isDirty).toBe(false); + }); + }); + + describe('findMergedWorktrees', () => { + it('should return only merged worktrees', () => { + const mergedPath = path.join(testRoot, 'worktree-merged'); + const unmergedPath = path.join(testRoot, 'worktree-unmerged'); + + createWorktree(repoRoot, mergedPath, 'feature-merged'); + createWorktree(repoRoot, unmergedPath, 'feature-unmerged'); + + // Make commits in both + makeCommit(mergedPath, 'merged.txt', 'merged', 'Merged feature'); + makeCommit(unmergedPath, 'unmerged.txt', 'unmerged', 'Unmerged feature'); + + // Merge only one branch + mergeBranch(repoRoot, 'feature-merged'); + + const merged = findMergedWorktrees(repoRoot, 'dev'); + + expect(merged).toHaveLength(1); + expect(merged[0].branch).toBe('feature-merged'); + }); + + it('should exclude dirty worktrees', () => { + const worktreePath = path.join(testRoot, 'worktree-merged-dirty'); + createWorktree(repoRoot, worktreePath, 'feature-merged-dirty'); + + // Make and merge a commit + makeCommit(worktreePath, 'feature.txt', 'feature', 'Add feature'); + mergeBranch(repoRoot, 'feature-merged-dirty'); + + // Add uncommitted changes + fs.writeFileSync(path.join(worktreePath, 'dirty.txt'), 'uncommitted'); + + const merged = findMergedWorktrees(repoRoot, 'dev'); + + // Should be excluded because it's dirty + expect(merged).toHaveLength(0); + }); + + it('should exclude main worktree', () => { + // The main worktree is never considered for cleanup + const merged = findMergedWorktrees(repoRoot, 'dev'); + + expect(merged.every(w => !w.isMain)).toBe(true); + }); + }); + + describe('cleanupWorktree', () => { + it('should remove worktree and delete branch', () => { + const worktreePath = path.join(testRoot, 'worktree-cleanup'); + createWorktree(repoRoot, worktreePath, 'feature-cleanup'); + + // Merge the branch + makeCommit(worktreePath, 'feature.txt', 'feature', 'Add feature'); + mergeBranch(repoRoot, 'feature-cleanup'); + + const worktrees = listWorktrees(repoRoot, 'dev'); + const toClean = worktrees.find(w => w.branch === 'feature-cleanup'); + + expect(toClean).toBeDefined(); + + const result = cleanupWorktree(repoRoot, toClean!); + + expect(result.success).toBe(true); + expect(result.actions.some(a => a.includes('Removed worktree'))).toBe(true); + expect(result.actions.some(a => a.includes('Deleted branch'))).toBe(true); + expect(fs.existsSync(worktreePath)).toBe(false); + }); + + it('should skip dirty worktrees unless forced', () => { + const worktreePath = path.join(testRoot, 'worktree-dirty'); + createWorktree(repoRoot, worktreePath, 'feature-dirty'); + + // Make it dirty + fs.writeFileSync(path.join(worktreePath, 'dirty.txt'), 'uncommitted'); + + const worktrees = listWorktrees(repoRoot, 'dev'); + const dirty = worktrees.find(w => w.branch === 'feature-dirty'); + + expect(dirty).toBeDefined(); + + // Should skip without force + const result = cleanupWorktree(repoRoot, dirty!); + + expect(result.success).toBe(false); + expect(result.actions.some(a => a.includes('Skipped dirty'))).toBe(true); + expect(fs.existsSync(worktreePath)).toBe(true); + }); + + it('should clean dirty worktrees when forced', () => { + const worktreePath = path.join(testRoot, 'worktree-dirty-force'); + createWorktree(repoRoot, worktreePath, 'feature-dirty-force'); + + // Make it dirty + fs.writeFileSync(path.join(worktreePath, 'dirty.txt'), 'uncommitted'); + + const worktrees = listWorktrees(repoRoot, 'dev'); + const dirty = worktrees.find(w => w.branch === 'feature-dirty-force'); + + expect(dirty).toBeDefined(); + + // Should succeed with force + const result = cleanupWorktree(repoRoot, dirty!, { force: true }); + + expect(result.success).toBe(true); + expect(fs.existsSync(worktreePath)).toBe(false); + }); + + it('should support dry-run mode', () => { + const worktreePath = path.join(testRoot, 'worktree-dryrun'); + createWorktree(repoRoot, worktreePath, 'feature-dryrun'); + + const worktrees = listWorktrees(repoRoot, 'dev'); + const toTest = worktrees.find(w => w.branch === 'feature-dryrun'); + + expect(toTest).toBeDefined(); + + const result = cleanupWorktree(repoRoot, toTest!, { dryRun: true }); + + expect(result.success).toBe(true); + expect(result.actions.some(a => a.includes('[dry-run]'))).toBe(true); + // Worktree should still exist + expect(fs.existsSync(worktreePath)).toBe(true); + }); + + it('should handle missing worktree path gracefully', () => { + const worktreePath = path.join(testRoot, 'worktree-missing'); + createWorktree(repoRoot, worktreePath, 'feature-missing'); + + // Manually delete the worktree directory + fs.rmSync(worktreePath, { recursive: true, force: true }); + + const worktrees = listWorktrees(repoRoot, 'dev'); + const missing = worktrees.find(w => w.branch === 'feature-missing'); + + expect(missing).toBeDefined(); + + const result = cleanupWorktree(repoRoot, missing!); + + // Should still succeed (prune will clean up) + expect(result.success).toBe(true); + expect(result.actions.some(a => a.includes('not found'))).toBe(true); + }); + + it('should never clean up main worktree', () => { + const worktrees = listWorktrees(repoRoot, 'dev'); + const main = worktrees.find(w => w.isMain); + + expect(main).toBeDefined(); + + const result = cleanupWorktree(repoRoot, main!); + + expect(result.success).toBe(false); + expect(result.actions.some(a => a.includes('Skipped main worktree'))).toBe(true); + }); + }); + + describe('cleanupMergedWorktrees', () => { + it('should clean all merged worktrees', () => { + const wt1Path = path.join(testRoot, 'worktree-1'); + const wt2Path = path.join(testRoot, 'worktree-2'); + + createWorktree(repoRoot, wt1Path, 'feature-1'); + createWorktree(repoRoot, wt2Path, 'feature-2'); + + // Merge both branches + makeCommit(wt1Path, 'f1.txt', 'f1', 'Feature 1'); + mergeBranch(repoRoot, 'feature-1'); + + makeCommit(wt2Path, 'f2.txt', 'f2', 'Feature 2'); + mergeBranch(repoRoot, 'feature-2'); + + const result = cleanupMergedWorktrees(repoRoot, 'dev'); + + expect(result.cleaned).toBe(2); + expect(result.skipped).toBe(0); + expect(fs.existsSync(wt1Path)).toBe(false); + expect(fs.existsSync(wt2Path)).toBe(false); + }); + + it('should report cleaned and skipped counts', () => { + const cleanPath = path.join(testRoot, 'worktree-clean'); + const dirtyPath = path.join(testRoot, 'worktree-dirty'); + + createWorktree(repoRoot, cleanPath, 'feature-clean'); + createWorktree(repoRoot, dirtyPath, 'feature-dirty'); + + // Merge both + makeCommit(cleanPath, 'clean.txt', 'clean', 'Clean feature'); + mergeBranch(repoRoot, 'feature-clean'); + + makeCommit(dirtyPath, 'dirty.txt', 'dirty', 'Dirty feature'); + mergeBranch(repoRoot, 'feature-dirty'); + + // Make the second one dirty + fs.writeFileSync(path.join(dirtyPath, 'uncommitted.txt'), 'uncommitted'); + + const result = cleanupMergedWorktrees(repoRoot, 'dev'); + + // Dirty worktree is excluded by findMergedWorktrees, so only 1 is cleaned + expect(result.cleaned).toBe(1); + expect(result.skipped).toBe(0); // Dirty one never enters cleanup (filtered out) + expect(fs.existsSync(cleanPath)).toBe(false); + expect(fs.existsSync(dirtyPath)).toBe(true); // Still exists + }); + + it('should continue on individual failure', () => { + const wt1Path = path.join(testRoot, 'worktree-ok'); + const wt2Path = path.join(testRoot, 'worktree-fail'); + + createWorktree(repoRoot, wt1Path, 'feature-ok'); + createWorktree(repoRoot, wt2Path, 'feature-fail'); + + // Merge both + makeCommit(wt1Path, 'ok.txt', 'ok', 'OK feature'); + mergeBranch(repoRoot, 'feature-ok'); + + makeCommit(wt2Path, 'fail.txt', 'fail', 'Fail feature'); + mergeBranch(repoRoot, 'feature-fail'); + + // Make the second one dirty so it gets skipped + fs.writeFileSync(path.join(wt2Path, 'dirty.txt'), 'dirty'); + + const result = cleanupMergedWorktrees(repoRoot, 'dev'); + + // Should continue despite one failure + expect(result.cleaned).toBeGreaterThanOrEqual(1); + expect(result.actions.length).toBeGreaterThan(0); + }); + + it('should report when no merged worktrees found', () => { + const result = cleanupMergedWorktrees(repoRoot, 'dev'); + + expect(result.cleaned).toBe(0); + expect(result.skipped).toBe(0); + expect(result.actions.some(a => a.includes('No merged worktrees'))).toBe(true); + }); + + it('should support dry-run mode for batch cleanup', () => { + const wt1Path = path.join(testRoot, 'worktree-dryrun-1'); + const wt2Path = path.join(testRoot, 'worktree-dryrun-2'); + + createWorktree(repoRoot, wt1Path, 'feature-dryrun-1'); + createWorktree(repoRoot, wt2Path, 'feature-dryrun-2'); + + // Merge both + makeCommit(wt1Path, 'f1.txt', 'f1', 'Feature 1'); + mergeBranch(repoRoot, 'feature-dryrun-1'); + + makeCommit(wt2Path, 'f2.txt', 'f2', 'Feature 2'); + mergeBranch(repoRoot, 'feature-dryrun-2'); + + const result = cleanupMergedWorktrees(repoRoot, 'dev', { dryRun: true }); + + expect(result.cleaned).toBe(2); + expect(result.actions.some(a => a.includes('[dry-run]'))).toBe(true); + // Worktrees should still exist + expect(fs.existsSync(wt1Path)).toBe(true); + expect(fs.existsSync(wt2Path)).toBe(true); + }); + }); +});