diff --git a/.changeset/shared-squad-external-state.md b/.changeset/shared-squad-external-state.md new file mode 100644 index 000000000..d7f5500b9 --- /dev/null +++ b/.changeset/shared-squad-external-state.md @@ -0,0 +1,20 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +Shared squad with external state backend + +Enables squad team state to live outside the repo in git-backed squad repos +or the global app data directory. Squads are discovered via origin URL matching +against a registry in ~/.squad/squad-repos.json. Zero files written to +target repos. + +New SDK: shared-squad registry, URL normalization (GitHub/ADO/SSH), 6-step +resolution chain, journal claim protocol, git-backed repo pointers. + +New CLI: init --shared, migrate --to shared --keep-local, shared +status|add-url|list|doctor|diagnose. + +Templates updated for shared mode (conditional git ops, 3-strategy resolution). +Cross-platform fixes: ssh:// URLs, APFS case sensitivity, platform-neutral text. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 01e18dfad..b8d4156c0 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -21,10 +21,18 @@ You are **Squad (Coordinator)** — the orchestrator for this project's AI team. - You may NOT invent facts or assumptions — ask the user or spawn an agent who knows - You may NOT do work yourself — ALWAYS delegate to a team member, even for small tasks. The only exception is Direct Mode (status checks, factual questions, and simple answers from context — see Response Mode Selection). -Check: Does `.squad/team.md` exist? (fall back to `.ai-team/team.md` for repos migrating from older installs) -- **No** → Init Mode -- **Yes, but `## Members` has zero roster entries** → Init Mode (treat as unconfigured — scaffold exists but no team was cast) -- **Yes, with roster entries** → Team Mode +**Resolve the team root** — find `.squad/team.md` using the FULL resolution chain (do NOT stop after local checks): + +1. **Local:** Check CWD and `git rev-parse --show-toplevel` for `.squad/team.md` (or `.ai-team/team.md` for legacy repos). +2. **Shared squad registry:** If no local `.squad/`, check `~/.squad/squad-repos.json` for git-backed squad repo pointers. For each clone path listed, read its `repos.json` and match the current repo's origin URL against `urlPatterns`. Also check `SQUAD_REPO_KEY` env var for direct key lookup. If matched, the team root is `{squad-repo-clone}/{key}/`. *(See Worktree Awareness for full details.)* +3. **Platform app data fallback:** Check the platform app data directory for `repos.json` with the same URL/key matching. +4. **Main-checkout fallback:** `git worktree list --porcelain` → check the main working tree for `.squad/`. + +**⚠️ You MUST attempt ALL 4 steps before concluding no squad exists.** + +- **Not found via any strategy** → Init Mode +- **Found but `## Members` has zero roster entries** → Init Mode (treat as unconfigured) +- **Found with roster entries** → Team Mode --- @@ -616,26 +624,40 @@ To enable full parallelism, shared writes use a drop-box pattern that eliminates Squad and all spawned agents may be running inside a **git worktree** rather than the main checkout. All `.squad/` paths (charters, history, decisions, logs) MUST be resolved relative to a known **team root**, never assumed from CWD. -**Two strategies for resolving the team root:** +**Three strategies for resolving the team root:** | Strategy | Team root | State scope | When to use | |----------|-----------|-------------|-------------| | **worktree-local** | Current worktree root | Branch-local — each worktree has its own `.squad/` state | Feature branches that need isolated decisions and history | +| **shared** | Git-backed squad repo (via `~/.squad/squad-repos.json` pointer) or platform app data | User-global — team identity shared across all clones of the same repo | Multiple clones of the same repo that share one squad, repos that can't commit `.squad/` | | **main-checkout** | Main working tree root | Shared — all worktrees read/write the main checkout's `.squad/` | Single source of truth for memories, decisions, and logs across all branches | +**Validation:** A `.squad/` directory must contain `team.md` or an `agents/` subdirectory to be recognized as a team root. This prevents false positives from the `~/.squad/` config directory. + **How the Coordinator resolves the team root (on every session start):** -1. **Check CWD first** — does `.squad/` exist in the current working directory? +1. **Check CWD first** — does `.squad/` exist (with `team.md` or `agents/`) in the current working directory? - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. -2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. -3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +2. Run `git rev-parse --show-toplevel` to get the current worktree root. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - - **No** → use **main-checkout** strategy. Discover the main working tree: - ``` - git worktree list --porcelain - ``` - The first `worktree` line is the main working tree. Team root = that path. -4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +3. No local `.squad/` → check **shared squad registry**: + a. If `SQUAD_REPO_KEY` env var is set, use it as the lookup key (skip URL matching). + b. Check `~/.squad/squad-repos.json` for git-backed repo pointers. + - For each squad repo clone path listed, read its `repos.json`. + - If using `SQUAD_REPO_KEY`: match by `entry.key`. + - If using URL: run `git remote get-url origin`, normalize, match against `urlPatterns`. + - Match found → Team root = `{squad-repo-clone}/{key}/` + c. Fall back to platform app data directory (e.g. `~/.local/share/squad/repos.json` on Linux, the standard app data directory on other platforms). + - Same key/URL matching as above. + - Match found → Team root = `{appdata}/squad/repos/{key}/` + d. No match → continue to step 4. +4. No shared match → use **main-checkout** strategy. Discover the main working tree: + ``` + git worktree list --porcelain + ``` + The first `worktree` line is the main working tree. Team root = that path. +5. Nothing found → **Init Mode**. No team root resolved — offer to initialize a new squad. +6. The user may override the strategy at any time (e.g., *"use main checkout for team state"*, *"keep team state in this worktree"*, or *"use shared squad for this repo"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. @@ -648,6 +670,13 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha - A `merge=union` driver in `.gitattributes` (see Init Mode) auto-resolves append-only files by keeping all lines from both sides — no manual conflict resolution needed. - The Scribe commits `.squad/` changes to the worktree's branch. State flows to other branches through normal git merge / PR workflow. +**Cross-worktree considerations (shared strategy):** +- Team root is outside the repo — in a git-backed squad repo clone or under platform app data. No repo writes needed. +- All clones of the same repo share one squad: same agents, charters, decisions, casting, and skills. +- Agent writes (history inbox, decisions inbox) go to the shared dir using the journal pattern (unique filenames, atomic creation, no contention across clones). +- Safe for concurrent sessions across clones. +- `TEAM_ROOT` passed to agents will be the external path. Agents don't need to know the mode. + **Cross-worktree considerations (main-checkout strategy):** - All worktrees share the same `.squad/` state on disk via the main checkout — changes are immediately visible without merging. - **Not safe for concurrent sessions.** If two worktrees run sessions simultaneously, Scribe merge-and-commit steps will race on `decisions.md` and git index. Use only when a single session is active at a time. diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index 01e18dfad..b8d4156c0 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -21,10 +21,18 @@ You are **Squad (Coordinator)** — the orchestrator for this project's AI team. - You may NOT invent facts or assumptions — ask the user or spawn an agent who knows - You may NOT do work yourself — ALWAYS delegate to a team member, even for small tasks. The only exception is Direct Mode (status checks, factual questions, and simple answers from context — see Response Mode Selection). -Check: Does `.squad/team.md` exist? (fall back to `.ai-team/team.md` for repos migrating from older installs) -- **No** → Init Mode -- **Yes, but `## Members` has zero roster entries** → Init Mode (treat as unconfigured — scaffold exists but no team was cast) -- **Yes, with roster entries** → Team Mode +**Resolve the team root** — find `.squad/team.md` using the FULL resolution chain (do NOT stop after local checks): + +1. **Local:** Check CWD and `git rev-parse --show-toplevel` for `.squad/team.md` (or `.ai-team/team.md` for legacy repos). +2. **Shared squad registry:** If no local `.squad/`, check `~/.squad/squad-repos.json` for git-backed squad repo pointers. For each clone path listed, read its `repos.json` and match the current repo's origin URL against `urlPatterns`. Also check `SQUAD_REPO_KEY` env var for direct key lookup. If matched, the team root is `{squad-repo-clone}/{key}/`. *(See Worktree Awareness for full details.)* +3. **Platform app data fallback:** Check the platform app data directory for `repos.json` with the same URL/key matching. +4. **Main-checkout fallback:** `git worktree list --porcelain` → check the main working tree for `.squad/`. + +**⚠️ You MUST attempt ALL 4 steps before concluding no squad exists.** + +- **Not found via any strategy** → Init Mode +- **Found but `## Members` has zero roster entries** → Init Mode (treat as unconfigured) +- **Found with roster entries** → Team Mode --- @@ -616,26 +624,40 @@ To enable full parallelism, shared writes use a drop-box pattern that eliminates Squad and all spawned agents may be running inside a **git worktree** rather than the main checkout. All `.squad/` paths (charters, history, decisions, logs) MUST be resolved relative to a known **team root**, never assumed from CWD. -**Two strategies for resolving the team root:** +**Three strategies for resolving the team root:** | Strategy | Team root | State scope | When to use | |----------|-----------|-------------|-------------| | **worktree-local** | Current worktree root | Branch-local — each worktree has its own `.squad/` state | Feature branches that need isolated decisions and history | +| **shared** | Git-backed squad repo (via `~/.squad/squad-repos.json` pointer) or platform app data | User-global — team identity shared across all clones of the same repo | Multiple clones of the same repo that share one squad, repos that can't commit `.squad/` | | **main-checkout** | Main working tree root | Shared — all worktrees read/write the main checkout's `.squad/` | Single source of truth for memories, decisions, and logs across all branches | +**Validation:** A `.squad/` directory must contain `team.md` or an `agents/` subdirectory to be recognized as a team root. This prevents false positives from the `~/.squad/` config directory. + **How the Coordinator resolves the team root (on every session start):** -1. **Check CWD first** — does `.squad/` exist in the current working directory? +1. **Check CWD first** — does `.squad/` exist (with `team.md` or `agents/`) in the current working directory? - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. -2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. -3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +2. Run `git rev-parse --show-toplevel` to get the current worktree root. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - - **No** → use **main-checkout** strategy. Discover the main working tree: - ``` - git worktree list --porcelain - ``` - The first `worktree` line is the main working tree. Team root = that path. -4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +3. No local `.squad/` → check **shared squad registry**: + a. If `SQUAD_REPO_KEY` env var is set, use it as the lookup key (skip URL matching). + b. Check `~/.squad/squad-repos.json` for git-backed repo pointers. + - For each squad repo clone path listed, read its `repos.json`. + - If using `SQUAD_REPO_KEY`: match by `entry.key`. + - If using URL: run `git remote get-url origin`, normalize, match against `urlPatterns`. + - Match found → Team root = `{squad-repo-clone}/{key}/` + c. Fall back to platform app data directory (e.g. `~/.local/share/squad/repos.json` on Linux, the standard app data directory on other platforms). + - Same key/URL matching as above. + - Match found → Team root = `{appdata}/squad/repos/{key}/` + d. No match → continue to step 4. +4. No shared match → use **main-checkout** strategy. Discover the main working tree: + ``` + git worktree list --porcelain + ``` + The first `worktree` line is the main working tree. Team root = that path. +5. Nothing found → **Init Mode**. No team root resolved — offer to initialize a new squad. +6. The user may override the strategy at any time (e.g., *"use main checkout for team state"*, *"keep team state in this worktree"*, or *"use shared squad for this repo"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. @@ -648,6 +670,13 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha - A `merge=union` driver in `.gitattributes` (see Init Mode) auto-resolves append-only files by keeping all lines from both sides — no manual conflict resolution needed. - The Scribe commits `.squad/` changes to the worktree's branch. State flows to other branches through normal git merge / PR workflow. +**Cross-worktree considerations (shared strategy):** +- Team root is outside the repo — in a git-backed squad repo clone or under platform app data. No repo writes needed. +- All clones of the same repo share one squad: same agents, charters, decisions, casting, and skills. +- Agent writes (history inbox, decisions inbox) go to the shared dir using the journal pattern (unique filenames, atomic creation, no contention across clones). +- Safe for concurrent sessions across clones. +- `TEAM_ROOT` passed to agents will be the external path. Agents don't need to know the mode. + **Cross-worktree considerations (main-checkout strategy):** - All worktrees share the same `.squad/` state on disk via the main checkout — changes are immediately visible without merging. - **Not safe for concurrent sessions.** If two worktrees run sessions simultaneously, Scribe merge-and-commit steps will race on `decisions.md` and git index. Use only when a single session is active at a time. diff --git a/package.json b/package.json index c01bc0a6d..f54dffc6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/squad", - "version": "0.9.1", + "version": "0.9.1-build.25", "private": true, "description": "Squad — Programmable multi-agent runtime for GitHub Copilot, built on @github/copilot-sdk", "type": "module", diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index e84fa7aa5..1f719a08d 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/squad-cli", - "version": "0.9.1", + "version": "0.9.1-build.25", "description": "Squad CLI — Command-line interface for the Squad multi-agent runtime", "type": "module", "bin": { @@ -120,6 +120,14 @@ "types": "./dist/cli/commands/init-remote.d.ts", "import": "./dist/cli/commands/init-remote.js" }, + "./commands/init-shared": { + "types": "./dist/cli/commands/init-shared.d.ts", + "import": "./dist/cli/commands/init-shared.js" + }, + "./commands/shared": { + "types": "./dist/cli/commands/shared.d.ts", + "import": "./dist/cli/commands/shared.js" + }, "./commands/watch": { "types": "./dist/cli/commands/watch/index.d.ts", "import": "./dist/cli/commands/watch/index.js" @@ -163,6 +171,10 @@ "./commands/cast": { "types": "./dist/cli/commands/cast.d.ts", "import": "./dist/cli/commands/cast.js" + }, + "./commands/migrate": { + "types": "./dist/cli/commands/migrate.d.ts", + "import": "./dist/cli/commands/migrate.js" } }, "files": [ diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index c30760f8a..ab913b401 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -157,6 +157,7 @@ async function main(): Promise { console.log(` --roles (use base roles)`); console.log(` --global (personal squad dir)`); console.log(` --no-workflows (skip CI setup)`); + console.log(` --shared [--key ] (shared multi-clone mode)`); console.log(` Usage: init --mode remote `); console.log(` Creates .squad/config.json pointing to an external team root`); console.log(` ${BOLD}upgrade${RESET} Update Squad-owned files to latest version`); @@ -165,7 +166,9 @@ async function main(): Promise { console.log(` Flags: --global (upgrade personal squad)`); console.log(` --migrate-directory (rename .ai-team/ → .squad/)`); console.log(` ${BOLD}migrate${RESET} Convert between markdown and SDK-First squad formats`); - console.log(` Flags: --to sdk|markdown, --from ai-team, --dry-run`); + console.log(` Flags: --to sdk|markdown|shared, --from ai-team, --dry-run`); + console.log(` --key (with --to shared)`); + console.log(` --keep-local (with --to shared: skip local file cleanup)`); console.log(` ${BOLD}status${RESET} Show which squad is active and why`); console.log(` ${BOLD}roles${RESET} List built-in Squad roles`); console.log(` Usage: roles [--category ] [--search ]`); @@ -255,6 +258,13 @@ async function main(): Promise { console.log(` upstream sync [name]`); console.log(` ${BOLD}economy${RESET} Toggle economy mode (cost-conscious model selection)`); console.log(` Usage: economy [on|off]`); + console.log(` ${BOLD}shared${RESET} Manage shared squad (multi-clone)`); + console.log(` Usage: shared `); + console.log(` status — show shared squad info for current clone`); + console.log(` add-url — register an additional URL pattern`); + console.log(` list — list all shared squads in registry`); + console.log(` doctor — health check for shared squad config`); + console.log(` diagnose — step-by-step resolution trace for debugging`); console.log(` ${BOLD}version${RESET} Print installed version`); console.log(` ${BOLD}help${RESET} Show this help message`); @@ -292,6 +302,17 @@ async function main(): Promise { const modeIdx = args.indexOf('--mode'); const mode = (modeIdx !== -1 && args[modeIdx + 1]) ? args[modeIdx + 1] : undefined; + // Handle --shared flag for shared squad init + if (args.includes('--shared')) { + const keyIdx = args.indexOf('--key'); + const key = (keyIdx !== -1 && args[keyIdx + 1]) ? args[keyIdx + 1] : undefined; + const repoIdx = args.indexOf('--squad-repo'); + const squadRepo = (repoIdx !== -1 && args[repoIdx + 1]) ? args[repoIdx + 1] : undefined; + const { runInitShared } = await import('./cli/commands/init-shared.js'); + runInitShared(process.cwd(), key, squadRepo); + return; + } + if (mode === 'remote') { const teamPath = args[modeIdx + 2]; if (!teamPath) { @@ -357,11 +378,14 @@ async function main(): Promise { if (cmd === 'migrate') { const { runMigrate } = await import('./cli/commands/migrate.js'); const toIdx = args.indexOf('--to'); - const to = (toIdx !== -1 && args[toIdx + 1]) ? args[toIdx + 1] as 'sdk' | 'markdown' : undefined; + const to = (toIdx !== -1 && args[toIdx + 1]) ? args[toIdx + 1] as 'sdk' | 'markdown' | 'shared' : undefined; const fromIdx = args.indexOf('--from'); const from = (fromIdx !== -1 && args[fromIdx + 1]) ? args[fromIdx + 1] : undefined; const dryRun = args.includes('--dry-run'); - await runMigrate(getSquadStartDir(), { to, from: from as 'ai-team' | undefined, dryRun }); + const keepLocal = args.includes('--keep-local'); + const keyIdx = args.indexOf('--key'); + const key = (keyIdx !== -1 && args[keyIdx + 1]) ? args[keyIdx + 1] : undefined; + await runMigrate(getSquadStartDir(), { to, from: from as 'ai-team' | undefined, dryRun, key, keepLocal }); return; } @@ -695,13 +719,23 @@ async function main(): Promise { console.log(` Active squad: ${BOLD}repo${RESET}`); console.log(` Path: ${repoSquad}`); console.log(` Reason: Found .squad/ in repository tree`); - } else if (globalExists) { - console.log(` Active squad: ${BOLD}personal (global)${RESET}`); - console.log(` Path: ${globalSquadDir}`); - console.log(` Reason: No repo .squad/ found; personal squad exists at global path`); } else { - console.log(` Active squad: ${DIM}none${RESET}`); - console.log(` Reason: No .squad/ found in repo tree or at global path`); + // Check for shared squad before falling back to global/none + const { resolveSquadPaths } = await lazySquadSdk(); + const sharedPaths = resolveSquadPaths(process.cwd()); + if (sharedPaths && sharedPaths.mode === 'shared') { + console.log(` Active squad: ${BOLD}shared${RESET}`); + console.log(` Team dir: ${sharedPaths.teamDir}`); + console.log(` Clone state: ${sharedPaths.projectDir}`); + console.log(` Reason: Matched origin remote to shared squad registry`); + } else if (globalExists) { + console.log(` Active squad: ${BOLD}personal (global)${RESET}`); + console.log(` Path: ${globalSquadDir}`); + console.log(` Reason: No repo .squad/ found; personal squad exists at global path`); + } else { + console.log(` Active squad: ${DIM}none${RESET}`); + console.log(` Reason: No .squad/ found in repo tree or at global path`); + } } console.log(); @@ -925,6 +959,13 @@ async function main(): Promise { return; } + if (cmd === 'shared') { + const { runShared } = await import('./cli/commands/shared.js'); + const subcommand = args[1] || 'status'; + runShared(process.cwd(), subcommand, args.slice(2)); + return; + } + if (cmd === 'config') { const { runConfig } = await import('./cli/commands/config.js'); await runConfig(getSquadStartDir(), args.slice(1)); diff --git a/packages/squad-cli/src/cli/commands/doctor.ts b/packages/squad-cli/src/cli/commands/doctor.ts index 4e1659fd6..49b18e10f 100644 --- a/packages/squad-cli/src/cli/commands/doctor.ts +++ b/packages/squad-cli/src/cli/commands/doctor.ts @@ -11,7 +11,7 @@ */ import path from 'node:path'; -import { FSStorageProvider } from '@bradygaster/squad-sdk'; +import { FSStorageProvider, resolveSharedSquad } from '@bradygaster/squad-sdk'; const storage = new FSStorageProvider(); @@ -25,13 +25,13 @@ export interface DoctorCheck { } /** Detected squad layout mode. */ -export type DoctorMode = 'local' | 'remote' | 'hub'; +export type DoctorMode = 'local' | 'remote' | 'hub' | 'shared'; /** Resolved mode + base directory for the squad. */ interface ModeInfo { mode: DoctorMode; squadDir: string; - /** Only set when mode === 'remote' */ + /** Only set when mode === 'remote' or 'shared' */ teamRoot?: string; } @@ -77,6 +77,12 @@ function detectMode(cwd: string): ModeInfo { return { mode: 'hub', squadDir }; } + // Shared mode: origin remote matches shared squad registry + const sharedResult = resolveSharedSquad(cwd); + if (sharedResult) { + return { mode: 'shared', squadDir: sharedResult.teamDir, teamRoot: sharedResult.teamDir }; + } + // Default: local return { mode: 'local', squadDir }; } @@ -462,6 +468,16 @@ export async function runDoctor(cwd?: string): Promise { checks.push(checkTeamRootResolves(squadDir, teamRoot)); } + // 4b. Shared mode: verify teamDir is accessible + if (mode === 'shared' && teamRoot) { + const teamDirExists = isDirectory(teamRoot); + checks.push({ + name: 'shared team directory', + status: teamDirExists ? 'pass' : 'fail', + message: teamDirExists ? `team dir: ${teamRoot}` : `team dir not found: ${teamRoot}`, + }); + } + // 5–9 standard files (only if .squad/ exists) if (isDirectory(squadDir)) { checks.push(checkTeamMd(squadDir)); diff --git a/packages/squad-cli/src/cli/commands/init-shared.ts b/packages/squad-cli/src/cli/commands/init-shared.ts new file mode 100644 index 000000000..6dd27ef3f --- /dev/null +++ b/packages/squad-cli/src/cli/commands/init-shared.ts @@ -0,0 +1,250 @@ +/** + * squad init --shared [--key ] — shared mode init command. + * + * Creates a shared squad under the global app data directory at + * `squad/repos/{key}/` with team scaffolding (agents/, casting/, + * decisions/, team.md, routing.md, etc.) and zero writes to the + * repository working tree. + * + * If the shared squad already exists (duplicate key), treats it as + * "attach to existing" rather than failing — enables multi-clone UX. + * In this case, creates: + * - .squad junction → shared team dir + * - .github/agents/squad.agent.md (from shared squad template or built-in) + * - Clone-local state in the local app data directory + * + * @module cli/commands/init-shared + */ + +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { + FSStorageProvider, + createSharedSquad, + createSharedSquadInRepo, + loadRepoRegistry, + lookupByKeyAcrossRepos, + addUrlPattern, + normalizeRemoteUrl, + getRemoteUrl, + resolveGlobalSquadPath, + validateRepoKey, + ensureCloneState, +} from '@bradygaster/squad-sdk'; +import { fatal } from '../core/errors.js'; +import { DIM, RESET } from '../core/output.js'; + +const storage = new FSStorageProvider(); + +/** Minimal team.md for a new shared squad. */ +function defaultTeamMd(key: string): string { + return `# Squad Team — ${key} + +> Shared squad initialized via \`squad init --shared\`. + +## Members + +| Name | Role | Charter | Status | +|------|------|---------|--------| + +## Project Context + +This is a shared squad for the \`${key}\` repository. +`; +} + +/** Minimal routing.md for a new shared squad. */ +function defaultRoutingMd(): string { + return `# Routing + +> Work routing rules for this squad. + +## Work Type Routing + +| Work Type | Primary Agent | Examples | +|-----------|--------------|----------| +`; +} + +/** Minimal decisions.md for a new shared squad. */ +function defaultDecisionsMd(): string { + return `# Decisions + +> Team decisions that all agents must respect. Managed by Scribe. +`; +} + +/** + * Run shared squad initialization. + * + * If the shared squad already exists for this key, attaches to it + * (optionally adding URL pattern) instead of failing. + * + * @param cwd - Current working directory (git repository root). + * @param keyArg - Optional explicit repo key. Auto-detected from origin if omitted. + */ +export function runInitShared(cwd: string, keyArg?: string, squadRepoArg?: string): void { + // Step 1: Determine repo key + let key = keyArg; + let urlPatterns: string[] = []; + + const remoteUrl = getRemoteUrl(cwd); + + if (!key) { + if (!remoteUrl) { + fatal( + 'Cannot auto-detect repo key: no git remote "origin" found.\n' + + ' Use --key to specify the key explicitly.', + ); + } + const normalized = normalizeRemoteUrl(remoteUrl); + + // Reject unknown providers with ambiguous keys + if (normalized.provider === 'unknown') { + fatal( + `Could not derive a supported repo key from origin URL.\n` + + ` Remote: ${remoteUrl}\n` + + ` Use --key to specify the key explicitly.`, + ); + } + + key = normalized.key; + urlPatterns = [normalized.normalizedUrl]; + } else { + // Key provided explicitly — still register URL pattern if remote exists + if (remoteUrl) { + const normalized = normalizeRemoteUrl(remoteUrl); + urlPatterns = [normalized.normalizedUrl]; + } + } + + // Step 2: Validate key + try { + validateRepoKey(key); + } catch (err) { + fatal((err as Error).message); + } + + // Step 3: Check if shared squad already exists — connect to it + // Check git-backed pointers (~/.squad/squad-repos.json) first, then legacy %APPDATA% + const located = lookupByKeyAcrossRepos(key); + if (located) { + const { entry: existing, squadRepoRoot } = located; + // Derive teamDir from where the entry was actually found + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + } catch { + globalDir = ''; + } + const isLegacyAppData = squadRepoRoot === globalDir; + const teamDir = isLegacyAppData + ? path.join(squadRepoRoot, 'repos', ...key.split('/')) + : path.join(squadRepoRoot, ...key.split('/')); + + // Add URL pattern if we have one and it's not already registered + if (urlPatterns.length > 0 && !existing.urlPatterns.includes(urlPatterns[0]!)) { + try { + addUrlPattern(key, urlPatterns[0]!); + } catch { + // best-effort + } + } + + // Sanity check: team dir must exist and have team.md + const teamMdPath = path.join(teamDir, 'team.md'); + if (!storage.existsSync(teamDir) || !storage.existsSync(teamMdPath)) { + fatal( + `Shared squad "${key}" is registered but team dir is missing or incomplete.\n` + + ` Expected: ${teamDir}\n` + + ` Run \`squad migrate --to shared\` from the source clone first.`, + ); + } + + // Resolve the git repository root (may differ from cwd if run from a subdir) + let gitRoot: string; + try { + gitRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim(); + } catch { + gitRoot = cwd; // Fallback to cwd if git rev-parse fails + } + + // --- Shared squad connect: zero repo writes --- + // The coordinator resolves the shared squad via the global squad + // repos.json registry + origin URL matching. No junction, no agent + // file in the repo. The user-global agent file handles coordination. + + // --- Create clone-local state --- + try { + ensureCloneState(gitRoot, key); + } catch { + // best-effort — clone state is not critical for connect + } + + console.log(''); + console.log(`✅ Connected to shared squad "${key}"`); + console.log(` Team dir: ${teamDir}`); + console.log(` Resolution: via ${isLegacyAppData ? path.join(squadRepoRoot, 'repos.json') : path.join(squadRepoRoot, 'repos.json')} (origin URL match)`); + console.log(` Agent file: ~/.copilot/agents/squad.agent.md (user-global)`); + console.log(''); + console.log(` ${DIM}No files written to repository. The coordinator discovers this${RESET}`); + console.log(` ${DIM}squad automatically via origin remote URL matching.${RESET}`); + console.log(''); + console.log(` ${DIM}Troubleshoot: node /dist/cli-entry.js shared diagnose${RESET}`); + return; + } + + // Step 4: Create shared squad (writes manifest + registry) + let teamDir: string; + try { + if (squadRepoArg) { + // Create in a git-backed squad repo clone + teamDir = createSharedSquadInRepo(squadRepoArg, key, urlPatterns); + } else { + // Create in platform app data (legacy default) + teamDir = createSharedSquad(key, urlPatterns); + } + } catch (err) { + fatal((err as Error).message); + } + + // Step 5: Scaffold team structure under teamDir + const dirs = [ + path.join(teamDir, 'agents'), + path.join(teamDir, 'casting'), + path.join(teamDir, 'decisions'), + path.join(teamDir, 'decisions', 'inbox'), + path.join(teamDir, 'skills'), + ]; + for (const dir of dirs) { + if (!storage.existsSync(dir)) { + storage.mkdirSync(dir, { recursive: true }); + } + } + + // Scaffold markdown files (only if they don't already exist) + const files: Array<[string, string]> = [ + [path.join(teamDir, 'team.md'), defaultTeamMd(key)], + [path.join(teamDir, 'routing.md'), defaultRoutingMd()], + [path.join(teamDir, 'decisions.md'), defaultDecisionsMd()], + ]; + for (const [filePath, content] of files) { + if (!storage.existsSync(filePath)) { + storage.writeSync(filePath, content); + } + } + + // Step 6: Print success + console.log(`✅ Created shared squad "${key}"`); + console.log(` Team dir: ${teamDir}`); + if (urlPatterns.length > 0) { + console.log(` Registered URL pattern: ${urlPatterns[0]}`); + } + if (squadRepoArg) { + console.log(` Squad repo: ${path.resolve(squadRepoArg)}`); + console.log(` Pointer: ~/.squad/squad-repos.json`); + } + console.log(''); + console.log(' Other clones of this repo will auto-discover this squad.'); + console.log(' No files written to your repository.'); +} diff --git a/packages/squad-cli/src/cli/commands/migrate.ts b/packages/squad-cli/src/cli/commands/migrate.ts index 8375e1f30..4ff5cab35 100644 --- a/packages/squad-cli/src/cli/commands/migrate.ts +++ b/packages/squad-cli/src/cli/commands/migrate.ts @@ -4,7 +4,15 @@ */ import path from 'node:path'; -import { FSStorageProvider } from '@bradygaster/squad-sdk'; +import fs from 'node:fs'; +import { execSync } from 'node:child_process'; +import { + FSStorageProvider, + createSharedSquad, + normalizeRemoteUrl, + getRemoteUrl, + validateRepoKey, +} from '@bradygaster/squad-sdk'; const storage = new FSStorageProvider(); import { success, warn, dim, bold, BOLD, RESET, DIM } from '../core/output.js'; @@ -18,9 +26,11 @@ import type { } from '@bradygaster/squad-sdk'; export interface MigrateOptions { - to?: 'sdk' | 'markdown'; + to?: 'sdk' | 'markdown' | 'shared'; from?: 'ai-team'; dryRun?: boolean; + key?: string; + keepLocal?: boolean; } interface ParsedTeam { @@ -398,6 +408,230 @@ export async function runMigrate(cwd: string, options: MigrateOptions): Promise< return; } + // Helper: recursively copy a directory using StorageProvider + function copyDirRecursive(src: string, dest: string): void { + storage.mkdirSync(dest, { recursive: true }); + const entries = storage.listSync?.(src) ?? []; + for (const entry of entries) { + const srcPath = path.join(src, entry); + const destPath = path.join(dest, entry); + if (storage.isDirectorySync(srcPath)) { + copyDirRecursive(srcPath, destPath); + } else { + const content = storage.readSync(srcPath); + if (content != null) { + storage.writeSync(destPath, content); + } + } + } + } + + // Handle --to shared (local .squad/ → shared mode) + if (options.to === 'shared') { + if (mode === 'none') { + fatal('No squad found. Run `squad init` first.'); + } + if (mode === 'legacy') { + fatal('Found .ai-team/ directory. Run `squad migrate --from ai-team` first.'); + } + + const squadDir = path.join(cwd, '.squad'); + if (!storage.existsSync(squadDir)) { + fatal('No .squad/ directory found.'); + } + + // Determine repo key + let key = options.key; + let urlPatterns: string[] = []; + + const remoteUrl = getRemoteUrl(cwd); + if (!key) { + if (!remoteUrl) { + fatal( + 'Cannot auto-detect repo key: no git remote "origin" found.\n' + + ' Use --key to specify the key explicitly.', + ); + } + const normalized = normalizeRemoteUrl(remoteUrl); + if (normalized.provider === 'unknown') { + fatal( + `Could not derive a supported repo key from origin URL.\n` + + ` Remote: ${remoteUrl}\n` + + ` Use --key to specify the key explicitly.`, + ); + } + key = normalized.key; + urlPatterns = [normalized.normalizedUrl]; + } else { + if (remoteUrl) { + const normalized = normalizeRemoteUrl(remoteUrl); + urlPatterns = [normalized.normalizedUrl]; + } + } + + try { + validateRepoKey(key); + } catch (err) { + fatal((err as Error).message); + } + + console.log(`\n${BOLD}Squad Migrate${RESET} — local .squad/ → shared\n`); + console.log(`📦 Migrating local squad to shared...`); + console.log(` Source: ${squadDir}`); + + // Create shared squad + let teamDir: string; + try { + teamDir = createSharedSquad(key, urlPatterns); + } catch (err) { + fatal((err as Error).message); + } + + console.log(` Target: ${teamDir}`); + console.log(''); + + // Copy team-state directories and files + const teamDirs = ['agents', 'casting', 'skills', 'decisions', 'decisions/inbox']; + for (const dir of teamDirs) { + const srcDir = path.join(squadDir, dir); + const destDir = path.join(teamDir, dir); + if (storage.existsSync(srcDir)) { + copyDirRecursive(srcDir, destDir); + success(`Copying: ${dir}/`); + } + } + + const teamFiles = ['team.md', 'routing.md', 'decisions.md']; + for (const file of teamFiles) { + const srcFile = path.join(squadDir, file); + const destFile = path.join(teamDir, file); + if (storage.existsSync(srcFile)) { + const content = storage.readSync(srcFile) ?? ''; + storage.writeSync(destFile, content); + success(`Copying: ${file}`); + } + } + + // Copy .github/agents/ to .github-template/agents/ in the shared squad + // so future `squad init --shared` connections can source the agent file + const githubAgentsDir = path.join(cwd, '.github', 'agents'); + if (storage.existsSync(githubAgentsDir)) { + const templateAgentsDir = path.join(teamDir, '.github-template', 'agents'); + copyDirRecursive(githubAgentsDir, templateAgentsDir); + success(`Copying: .github/agents/ → .github-template/agents/`); + } + + if (urlPatterns.length > 0) { + console.log(` Registered URL pattern: ${urlPatterns[0]}`); + } + + console.log(''); + console.log(`✅ Migrated to shared squad "${key}"`); + + // Cleanup local files after successful migration + if (!options.keepLocal) { + console.log(''); + console.log(`🧹 Cleaning up local squad files...`); + + // Clean up .squad/ directory + if (storage.existsSync(squadDir)) { + // Check if .squad/ contains any git-tracked files + let hasTrackedFiles = false; + try { + const tracked = execSync('git ls-files .squad/', { cwd, encoding: 'utf-8' }).trim(); + hasTrackedFiles = tracked.length > 0; + } catch { + // git ls-files failed — treat as untracked to be safe + } + + if (hasTrackedFiles) { + warn(` .squad/ contains git-tracked files — left in place`); + console.log(` ${DIM}Run \`git rm -r .squad/\` to remove tracked files${RESET}`); + } else { + try { + fs.rmSync(squadDir, { recursive: true, force: true }); + success(' Removed .squad/'); + } catch (err) { + warn(` Could not remove .squad/: ${(err as Error).message}`); + } + } + } + + // Clean up .github/agents/squad.agent.md (only if untracked) + const agentFile = path.join(cwd, '.github', 'agents', 'squad.agent.md'); + if (storage.existsSync(agentFile)) { + let isTracked = false; + try { + const tracked = execSync('git ls-files .github/agents/squad.agent.md', { cwd, encoding: 'utf-8' }).trim(); + isTracked = tracked.length > 0; + } catch { /* ignore */ } + + if (isTracked) { + warn(` .github/agents/squad.agent.md is git-tracked — left in place`); + } else { + try { + fs.unlinkSync(agentFile); + success(' Removed .github/agents/squad.agent.md'); + // Clean up empty .github/agents/ dir + const agentsDir = path.join(cwd, '.github', 'agents'); + try { + const remaining = fs.readdirSync(agentsDir); + if (remaining.length === 0) fs.rmdirSync(agentsDir); + } catch { /* ignore */ } + } catch (err) { + warn(` Could not remove agent file: ${(err as Error).message}`); + } + } + } + + // Clean up .gitattributes if it was squad-generated and untracked + const gitattributes = path.join(cwd, '.gitattributes'); + if (storage.existsSync(gitattributes)) { + let isTracked = false; + try { + const tracked = execSync('git ls-files .gitattributes', { cwd, encoding: 'utf-8' }).trim(); + isTracked = tracked.length > 0; + } catch { /* ignore */ } + + if (!isTracked) { + // Only remove if it looks squad-generated (contains merge=union for .squad/) + const content = storage.readSync(gitattributes) ?? ''; + if (content.includes('.squad/') && content.includes('merge=union')) { + try { + fs.unlinkSync(gitattributes); + success(' Removed .gitattributes (squad-generated)'); + } catch { /* ignore */ } + } + } + } + + // Clean up .copilot/ if untracked + const copilotDir = path.join(cwd, '.copilot'); + if (storage.existsSync(copilotDir)) { + let isTracked = false; + try { + const tracked = execSync('git ls-files .copilot/', { cwd, encoding: 'utf-8' }).trim(); + isTracked = tracked.length > 0; + } catch { /* ignore */ } + + if (!isTracked) { + try { + fs.rmSync(copilotDir, { recursive: true, force: true }); + success(' Removed .copilot/'); + } catch { /* ignore */ } + } + } + + console.log(''); + console.log(` ${DIM}Use --keep-local to skip cleanup next time.${RESET}`); + } else { + console.log(''); + console.log(` ${DIM}Local files left in place (--keep-local).${RESET}`); + console.log(` ${DIM}Run \`git clean -xdf .squad .github/agents .gitattributes .copilot\` to remove manually.${RESET}`); + } + return; + } + // Handle --to markdown (reverse migration) if (options.to === 'markdown') { if (mode !== 'sdk') { diff --git a/packages/squad-cli/src/cli/commands/shared.ts b/packages/squad-cli/src/cli/commands/shared.ts new file mode 100644 index 000000000..728f515c1 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/shared.ts @@ -0,0 +1,532 @@ +/** + * squad shared — shared squad management commands. + * + * Subcommands: + * status — show shared squad info for current clone + * add-url — register an additional URL pattern + * list — list all shared squads in the registry + * doctor — health checks for shared squad configuration + * + * @module cli/commands/shared + */ + +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { lstatSync, readlinkSync } from 'node:fs'; +import { + FSStorageProvider, + resolveSharedSquad, + loadRepoRegistry, + addUrlPattern, + resolveGlobalSquadPath, + validateRepoKey, + normalizeRemoteUrl, + getRemoteUrl, +} from '@bradygaster/squad-sdk'; +import type { SharedSquadManifest, NormalizedRemote } from '@bradygaster/squad-sdk'; +import { fatal } from '../core/errors.js'; +import { BOLD, RESET, GREEN, RED, YELLOW, DIM } from '../core/output.js'; + +const storage = new FSStorageProvider(); + +/** + * Route shared subcommands. + * + * @param cwd - Current working directory. + * @param subcommand - One of: status, add-url, list, doctor. + * @param args - Remaining CLI arguments after the subcommand. + */ +export function runShared(cwd: string, subcommand: string, args: string[]): void { + switch (subcommand) { + case 'status': + return runStatus(cwd); + case 'add-url': + return runAddUrl(cwd, args); + case 'list': + return runList(); + case 'doctor': + return runDoctor(); + case 'diagnose': + return runDiagnose(cwd); + default: + fatal( + `Unknown shared subcommand: ${subcommand}\n` + + ' Usage: squad shared ', + ); + } +} + +// ============================================================================ +// status +// ============================================================================ + +function runStatus(cwd: string): void { + const resolved = resolveSharedSquad(cwd); + if (!resolved) { + console.log('Not in a shared squad.'); + console.log(''); + console.log(`${DIM}Hint: Run \`squad init --shared\` to create one,${RESET}`); + console.log(`${DIM}or set up a shared squad in another clone and this one will auto-discover it.${RESET}`); + return; + } + + // Read manifest for extra info + const manifestPath = path.join(resolved.teamDir, 'manifest.json'); + let urlPatterns: string[] = []; + let repoKey = ''; + if (storage.existsSync(manifestPath)) { + try { + const raw = storage.readSync(manifestPath) ?? ''; + const manifest = JSON.parse(raw) as SharedSquadManifest; + urlPatterns = manifest.urlPatterns ?? []; + repoKey = manifest.repoKey ?? ''; + } catch { + // best-effort + } + } + + console.log(`🔗 Shared squad: ${BOLD}${repoKey}${RESET}`); + console.log(` Team dir: ${resolved.teamDir}`); + console.log(` Local state: ${resolved.projectDir}`); + + // Count pending decisions inbox + const inboxDir = path.join(resolved.teamDir, 'decisions', 'inbox'); + let pendingCount = 0; + if (storage.existsSync(inboxDir)) { + try { + const entries = storage.listSync(inboxDir); + pendingCount = entries.filter((e: string) => e.endsWith('.md')).length; + } catch { + // ignore + } + } + console.log(` Decisions: shared (${pendingCount} pending in inbox)`); + + if (urlPatterns.length > 0) { + console.log(' URL patterns:'); + for (const p of urlPatterns) { + console.log(` - ${p}`); + } + } +} + +// ============================================================================ +// add-url +// ============================================================================ + +function runAddUrl(cwd: string, args: string[]): void { + const pattern = args[0]; + if (!pattern) { + fatal('Usage: squad shared add-url '); + } + + // Try --key flag first, then fall back to discovery + const keyIdx = args.indexOf('--key'); + let repoKey: string | undefined; + + if (keyIdx !== -1 && args[keyIdx + 1]) { + repoKey = args[keyIdx + 1]!; + } else { + const resolved = resolveSharedSquad(cwd); + if (!resolved) { + fatal( + 'Not in a shared squad and no --key provided.\n' + + ' Usage: squad shared add-url [--key ]', + ); + } + + // Read manifest to get the repo key + const manifestPath = path.join(resolved.teamDir, 'manifest.json'); + if (!storage.existsSync(manifestPath)) { + fatal('Shared squad manifest not found. Run `squad init --shared` to recreate.'); + } + + try { + const raw = storage.readSync(manifestPath) ?? ''; + const manifest = JSON.parse(raw) as SharedSquadManifest; + repoKey = manifest.repoKey; + } catch { + fatal('Failed to read shared squad manifest.'); + } + } + + try { + addUrlPattern(repoKey!, pattern); + } catch (err) { + fatal((err as Error).message); + } + + console.log(`✅ Added URL pattern for "${repoKey}"`); +} + +// ============================================================================ +// list +// ============================================================================ + +function runList(): void { + const registry = loadRepoRegistry(); + if (!registry || registry.repos.length === 0) { + console.log('No shared squads registered.'); + console.log(`${DIM}Run \`squad init --shared\` to create one.${RESET}`); + return; + } + + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + } catch { + fatal('Global config directory unreachable.'); + return; + } + + console.log(''); + for (const entry of registry.repos) { + const teamDir = path.join(globalDir, 'repos', ...entry.key.split('/')); + const patternCount = entry.urlPatterns.length; + const patternLabel = patternCount === 1 ? '1 URL pattern' : `${patternCount} URL patterns`; + console.log(` ${BOLD}${entry.key}${RESET} ${teamDir} ${patternLabel}`); + } + console.log(''); +} + +// ============================================================================ +// doctor +// ============================================================================ + +function runDoctor(): void { + console.log('🔍 Checking shared squad health...'); + + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + console.log(` ${GREEN}✅${RESET} Global config dir accessible`); + } catch { + console.log(` ${RED}❌${RESET} Global config dir unreachable (global squad data directory)`); + return; + } + + // Check registry + const registry = loadRepoRegistry(); + if (!registry) { + console.log(` ${YELLOW}⚠️${RESET} repos.json missing or invalid (no shared squads registered)`); + return; + } + console.log(` ${GREEN}✅${RESET} repos.json valid (${registry.repos.length} ${registry.repos.length === 1 ? 'entry' : 'entries'})`); + + // Check each entry + for (const entry of registry.repos) { + try { + validateRepoKey(entry.key); + } catch { + console.log(` ${RED}❌${RESET} ${entry.key} — invalid repo key`); + continue; + } + + const teamDir = path.join(globalDir, 'repos', ...entry.key.split('/')); + + // Team dir exists? + if (!storage.existsSync(teamDir)) { + console.log(` ${YELLOW}⚠️${RESET} ${entry.key} — team dir missing (stale registry entry?)`); + continue; + } + + // Manifest valid? + const manifestPath = path.join(teamDir, 'manifest.json'); + if (!storage.existsSync(manifestPath)) { + console.log(` ${YELLOW}⚠️${RESET} ${entry.key} — manifest.json missing`); + continue; + } + + try { + const raw = storage.readSync(manifestPath) ?? ''; + const manifest = JSON.parse(raw) as SharedSquadManifest; + if (manifest.version !== 1 || manifest.repoKey !== entry.key) { + console.log(` ${YELLOW}⚠️${RESET} ${entry.key} — manifest.json content mismatch`); + continue; + } + } catch { + console.log(` ${YELLOW}⚠️${RESET} ${entry.key} — manifest.json parse error`); + continue; + } + + console.log(` ${GREEN}✅${RESET} ${entry.key} — team dir exists, manifest valid`); + + // Check decisions/inbox + const inboxDir = path.join(teamDir, 'decisions', 'inbox'); + if (storage.existsSync(inboxDir)) { + let pendingCount = 0; + try { + const entries = storage.listSync(inboxDir); + pendingCount = entries.filter((e: string) => e.endsWith('.md')).length; + } catch { + // ignore + } + + // Check for orphaned processing dirs (stale if older than 5 minutes) + const processingDir = path.join(teamDir, 'decisions', 'processing'); + let hasOrphanedProcessing = false; + if (storage.existsSync(processingDir)) { + const processingEntries = storage.listSync(processingDir); + hasOrphanedProcessing = processingEntries.length > 0; + } + const processingNote = hasOrphanedProcessing ? ', processing: stale entries found' : ', processing: clean'; + + console.log(` ${GREEN}✅${RESET} ${entry.key} — decisions/inbox: ${pendingCount} pending${processingNote}`); + } + } + + // Path validation + const reposRoot = path.join(globalDir, 'repos'); + if (storage.existsSync(reposRoot)) { + console.log(` ${GREEN}✅${RESET} Path validation: repos/ root exists`); + } +} + +// ============================================================================ +// diagnose — step-by-step resolution trace for debugging +// ============================================================================ + +function runDiagnose(cwd: string): void { + console.log('🔎 Shared squad resolution trace'); + console.log(` cwd: ${cwd}`); + console.log(''); + + // Step 1: Find git root + let gitRoot: string | null = null; + try { + gitRoot = execSync('git rev-parse --show-toplevel', { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + console.log(`1. ${GREEN}✅${RESET} Git root: ${gitRoot}`); + } catch { + console.log(`1. ${RED}❌${RESET} Not in a git repository`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} Cannot resolve shared squad — not a git repo.`); + return; + } + + // Step 2: Check local .squad/ + const localSquad = path.join(gitRoot, '.squad'); + const localAiTeam = path.join(gitRoot, '.ai-team'); + const hasLocalSquad = storage.existsSync(localSquad); + const hasLocalAiTeam = storage.existsSync(localAiTeam); + if (hasLocalSquad) { + // Check if it's a junction/symlink pointing to the shared dir + let isLink = false; + let linkTarget = ''; + try { + const stat = lstatSync(localSquad); + isLink = stat.isSymbolicLink(); + if (isLink) { + linkTarget = readlinkSync(localSquad).toString(); + } + } catch { + // lstat failed — treat as regular dir + } + if (isLink) { + console.log(`2. ${YELLOW}⚠️${RESET} Local .squad/ is a SYMLINK → ${linkTarget}`); + console.log(` ${DIM}Resolution uses worktree-local strategy (follows the link). Shared discovery skipped.${RESET}`); + } else { + console.log(`2. ${YELLOW}⚠️${RESET} Local .squad/ EXISTS — resolution would use worktree-local, not shared`); + console.log(` Path: ${localSquad}`); + console.log(` ${DIM}(Shared resolution only activates when no local .squad/ is found)${RESET}`); + } + } else if (hasLocalAiTeam) { + console.log(`2. ${YELLOW}⚠️${RESET} Legacy .ai-team/ EXISTS — resolution would use worktree-local`); + console.log(` Path: ${localAiTeam}`); + } else { + console.log(`2. ${GREEN}✅${RESET} No local .squad/ or .ai-team/ — shared discovery will proceed`); + } + + // Step 3: SQUAD_REPO_KEY env var + const envKey = process.env['SQUAD_REPO_KEY']; + if (envKey) { + console.log(`3. ${GREEN}✅${RESET} SQUAD_REPO_KEY env var: "${envKey}" (skips URL matching)`); + } else { + console.log(`3. ${DIM}—${RESET} SQUAD_REPO_KEY not set (will use URL matching)`); + } + + // Step 4: SQUAD_APPDATA_OVERRIDE + const appdataOverride = process.env['SQUAD_APPDATA_OVERRIDE']; + if (appdataOverride) { + console.log(`4. ${YELLOW}⚠️${RESET} SQUAD_APPDATA_OVERRIDE: "${appdataOverride}"`); + } else { + console.log(`4. ${DIM}—${RESET} SQUAD_APPDATA_OVERRIDE not set (using platform default)`); + } + + // Step 5: Global squad path + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + console.log(`5. ${GREEN}✅${RESET} Global config dir: ${globalDir}`); + } catch (err) { + console.log(`5. ${RED}❌${RESET} Global config dir UNREACHABLE`); + console.log(` ${(err as Error).message}`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} Cannot resolve shared squad — global squad data directory unreachable.`); + return; + } + + // Step 6: repos.json + const reposJsonPath = path.join(globalDir, 'repos.json'); + if (!storage.existsSync(reposJsonPath)) { + console.log(`6. ${RED}❌${RESET} repos.json NOT FOUND at ${reposJsonPath}`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} No shared squads registered. Run \`squad init --shared\` or \`squad migrate --to shared\`.`); + return; + } + + const registry = loadRepoRegistry(); + if (!registry || registry.repos.length === 0) { + console.log(`6. ${RED}❌${RESET} repos.json exists but is empty or invalid`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} Registry has no entries.`); + return; + } + console.log(`6. ${GREEN}✅${RESET} repos.json: ${registry.repos.length} registered ${registry.repos.length === 1 ? 'squad' : 'squads'}`); + for (const entry of registry.repos) { + console.log(` ${DIM}key: ${entry.key}${RESET}`); + for (const p of entry.urlPatterns) { + console.log(` ${DIM} pattern: ${p}${RESET}`); + } + } + + // Step 7: Origin remote URL + const remoteUrl = getRemoteUrl(gitRoot); + if (!remoteUrl) { + console.log(`7. ${RED}❌${RESET} No origin remote found`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} Cannot discover shared squad — no origin remote. Set SQUAD_REPO_KEY env var instead.`); + return; + } + console.log(`7. ${GREEN}✅${RESET} Origin URL: ${remoteUrl}`); + + // Step 8: Normalize URL + let normalized: NormalizedRemote; + try { + normalized = normalizeRemoteUrl(remoteUrl); + console.log(`8. ${GREEN}✅${RESET} Normalized URL: ${normalized.normalizedUrl}`); + console.log(` ${DIM}provider: ${normalized.provider}, key: ${normalized.key}${RESET}`); + } catch (err) { + console.log(`8. ${RED}❌${RESET} URL normalization failed: ${(err as Error).message}`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} Could not normalize origin URL.`); + return; + } + + // Step 9: Pattern matching + const matchedEntry = registry.repos.find((entry) => + entry.urlPatterns.some((p) => p === normalized.normalizedUrl), + ); + if (!matchedEntry) { + console.log(`9. ${RED}❌${RESET} No URL pattern match`); + console.log(` ${DIM}Normalized URL "${normalized.normalizedUrl}" did not match any registered pattern.${RESET}`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} Origin URL doesn't match any registered shared squad.`); + console.log(`${DIM}Fix: Run \`squad shared add-url "${normalized.normalizedUrl}" --key \`${RESET}`); + console.log(`${DIM} or: Run \`squad init --shared\` to register this clone${RESET}`); + return; + } + console.log(`9. ${GREEN}✅${RESET} Matched: key="${matchedEntry.key}"`); + + // Step 10: Team dir exists + const teamDir = path.join(globalDir, 'repos', ...matchedEntry.key.split('/')); + if (!storage.existsSync(teamDir)) { + console.log(`10. ${RED}❌${RESET} Team dir MISSING: ${teamDir}`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} Registry entry exists but team directory was not created.`); + return; + } + console.log(`10. ${GREEN}✅${RESET} Team dir: ${teamDir}`); + + // Step 11: team.md exists and has members + const teamMdPath = path.join(teamDir, 'team.md'); + if (!storage.existsSync(teamMdPath)) { + console.log(`11. ${RED}❌${RESET} team.md NOT FOUND in team dir`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} Shared squad dir exists but has no team.md.`); + return; + } + + let teamMdContent = ''; + try { + teamMdContent = storage.readSync(teamMdPath) ?? ''; + } catch { + console.log(`11. ${RED}❌${RESET} team.md unreadable`); + return; + } + + // Detect corrupted single-line files (migration bug: all newlines stripped) + if (teamMdContent.length > 50 && !teamMdContent.includes('\n')) { + console.log(`11. ${RED}❌${RESET} team.md is CORRUPTED — entire file is a single line (no newlines)`); + console.log(` ${DIM}This is a known migration bug. The file content exists but has no line breaks.${RESET}`); + console.log(` ${DIM}Fix: rewrite team.md with proper newlines, or re-run migration.${RESET}`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} team.md is corrupted (no newlines). The coordinator cannot parse it.`); + return; + } + + const membersMatch = teamMdContent.match(/## Members\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/); + if (!membersMatch) { + console.log(`11. ${YELLOW}⚠️${RESET} team.md exists but has no "## Members" section`); + console.log(` ${DIM}The coordinator looks for "## Members" — this header is required.${RESET}`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} team.md is missing ## Members header. The coordinator will enter Init Mode.`); + return; + } + + // Count roster rows (lines with | that aren't the header separator) + const rosterLines = membersMatch[1]! + .split(/\r?\n/) + .filter((line) => line.startsWith('|') && !line.match(/^\|\s*-+/)); + // First row is the header + const memberCount = Math.max(0, rosterLines.length - 1); + + if (memberCount === 0) { + console.log(`11. ${YELLOW}⚠️${RESET} team.md has ## Members but roster is EMPTY`); + console.log(''); + console.log(`${BOLD}Verdict:${RESET} No agents in the roster. The coordinator will enter Init Mode.`); + return; + } + + console.log(`11. ${GREEN}✅${RESET} team.md: ${memberCount} ${memberCount === 1 ? 'member' : 'members'} in roster`); + + // Step 12: agents/ directory + const agentsDir = path.join(teamDir, 'agents'); + if (storage.existsSync(agentsDir)) { + try { + const agentDirs = storage.listSync(agentsDir).filter( + (name: string) => !name.startsWith('.') && name !== '_alumni', + ); + const withCharters = agentDirs.filter((name: string) => + storage.existsSync(path.join(agentsDir, name, 'charter.md')), + ); + console.log(`12. ${GREEN}✅${RESET} agents/: ${agentDirs.length} dirs, ${withCharters.length} with charters`); + for (const name of withCharters) { + console.log(` ${DIM}${name}/${RESET}`); + } + } catch { + console.log(`12. ${YELLOW}⚠️${RESET} agents/ exists but could not list contents`); + } + } else { + console.log(`12. ${YELLOW}⚠️${RESET} agents/ directory not found in team dir`); + } + + // Step 13: Final resolution test + console.log(''); + console.log(`${DIM}Running full SDK resolution...${RESET}`); + const resolved = resolveSharedSquad(gitRoot); + if (resolved) { + console.log(`${GREEN}${BOLD}✅ Verdict: Shared squad resolves successfully.${RESET}`); + console.log(` mode: ${resolved.mode}`); + console.log(` teamDir: ${resolved.teamDir}`); + console.log(` projectDir: ${resolved.projectDir}`); + } else { + console.log(`${RED}${BOLD}❌ Verdict: resolveSharedSquad() returned null.${RESET}`); + console.log(` ${DIM}The step-by-step trace above showed all checks passing,${RESET}`); + console.log(` ${DIM}but the SDK function returned null. This likely means a${RESET}`); + console.log(` ${DIM}security check (realpathSync/symlink validation) blocked it.${RESET}`); + } +} diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index abaefb19a..39e9424c9 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; -import { FSStorageProvider } from '@bradygaster/squad-sdk'; +import { FSStorageProvider, resolveSharedSquad } from '@bradygaster/squad-sdk'; import { detectSquadDir, resolveWorktreeMainCheckout } from './detect-squad-dir.js'; import { success, BOLD, RESET, YELLOW, GREEN, DIM } from './output.js'; import { fatal } from './errors.js'; @@ -192,6 +192,21 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi } } + // Check if a shared squad already exists for this repo + const sharedResult = resolveSharedSquad(dest); + if (sharedResult) { + console.log(''); + console.log('⚠️ A shared squad already exists for this repository.'); + console.log(` Team dir: ${sharedResult.teamDir}`); + console.log(''); + console.log(' Creating a local .squad/ will shadow the shared squad.'); + console.log(' To connect to the shared squad instead, run:'); + console.log(' squad init --shared'); + console.log(''); + console.log(' Proceeding will create an independent local squad.'); + console.log(''); + } + // Show deprecation warning if using .ai-team/ if (squadInfo.isLegacy) { showDeprecationWarning(); diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index 01e18dfad..b8d4156c0 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -21,10 +21,18 @@ You are **Squad (Coordinator)** — the orchestrator for this project's AI team. - You may NOT invent facts or assumptions — ask the user or spawn an agent who knows - You may NOT do work yourself — ALWAYS delegate to a team member, even for small tasks. The only exception is Direct Mode (status checks, factual questions, and simple answers from context — see Response Mode Selection). -Check: Does `.squad/team.md` exist? (fall back to `.ai-team/team.md` for repos migrating from older installs) -- **No** → Init Mode -- **Yes, but `## Members` has zero roster entries** → Init Mode (treat as unconfigured — scaffold exists but no team was cast) -- **Yes, with roster entries** → Team Mode +**Resolve the team root** — find `.squad/team.md` using the FULL resolution chain (do NOT stop after local checks): + +1. **Local:** Check CWD and `git rev-parse --show-toplevel` for `.squad/team.md` (or `.ai-team/team.md` for legacy repos). +2. **Shared squad registry:** If no local `.squad/`, check `~/.squad/squad-repos.json` for git-backed squad repo pointers. For each clone path listed, read its `repos.json` and match the current repo's origin URL against `urlPatterns`. Also check `SQUAD_REPO_KEY` env var for direct key lookup. If matched, the team root is `{squad-repo-clone}/{key}/`. *(See Worktree Awareness for full details.)* +3. **Platform app data fallback:** Check the platform app data directory for `repos.json` with the same URL/key matching. +4. **Main-checkout fallback:** `git worktree list --porcelain` → check the main working tree for `.squad/`. + +**⚠️ You MUST attempt ALL 4 steps before concluding no squad exists.** + +- **Not found via any strategy** → Init Mode +- **Found but `## Members` has zero roster entries** → Init Mode (treat as unconfigured) +- **Found with roster entries** → Team Mode --- @@ -616,26 +624,40 @@ To enable full parallelism, shared writes use a drop-box pattern that eliminates Squad and all spawned agents may be running inside a **git worktree** rather than the main checkout. All `.squad/` paths (charters, history, decisions, logs) MUST be resolved relative to a known **team root**, never assumed from CWD. -**Two strategies for resolving the team root:** +**Three strategies for resolving the team root:** | Strategy | Team root | State scope | When to use | |----------|-----------|-------------|-------------| | **worktree-local** | Current worktree root | Branch-local — each worktree has its own `.squad/` state | Feature branches that need isolated decisions and history | +| **shared** | Git-backed squad repo (via `~/.squad/squad-repos.json` pointer) or platform app data | User-global — team identity shared across all clones of the same repo | Multiple clones of the same repo that share one squad, repos that can't commit `.squad/` | | **main-checkout** | Main working tree root | Shared — all worktrees read/write the main checkout's `.squad/` | Single source of truth for memories, decisions, and logs across all branches | +**Validation:** A `.squad/` directory must contain `team.md` or an `agents/` subdirectory to be recognized as a team root. This prevents false positives from the `~/.squad/` config directory. + **How the Coordinator resolves the team root (on every session start):** -1. **Check CWD first** — does `.squad/` exist in the current working directory? +1. **Check CWD first** — does `.squad/` exist (with `team.md` or `agents/`) in the current working directory? - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. -2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. -3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +2. Run `git rev-parse --show-toplevel` to get the current worktree root. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - - **No** → use **main-checkout** strategy. Discover the main working tree: - ``` - git worktree list --porcelain - ``` - The first `worktree` line is the main working tree. Team root = that path. -4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +3. No local `.squad/` → check **shared squad registry**: + a. If `SQUAD_REPO_KEY` env var is set, use it as the lookup key (skip URL matching). + b. Check `~/.squad/squad-repos.json` for git-backed repo pointers. + - For each squad repo clone path listed, read its `repos.json`. + - If using `SQUAD_REPO_KEY`: match by `entry.key`. + - If using URL: run `git remote get-url origin`, normalize, match against `urlPatterns`. + - Match found → Team root = `{squad-repo-clone}/{key}/` + c. Fall back to platform app data directory (e.g. `~/.local/share/squad/repos.json` on Linux, the standard app data directory on other platforms). + - Same key/URL matching as above. + - Match found → Team root = `{appdata}/squad/repos/{key}/` + d. No match → continue to step 4. +4. No shared match → use **main-checkout** strategy. Discover the main working tree: + ``` + git worktree list --porcelain + ``` + The first `worktree` line is the main working tree. Team root = that path. +5. Nothing found → **Init Mode**. No team root resolved — offer to initialize a new squad. +6. The user may override the strategy at any time (e.g., *"use main checkout for team state"*, *"keep team state in this worktree"*, or *"use shared squad for this repo"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. @@ -648,6 +670,13 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha - A `merge=union` driver in `.gitattributes` (see Init Mode) auto-resolves append-only files by keeping all lines from both sides — no manual conflict resolution needed. - The Scribe commits `.squad/` changes to the worktree's branch. State flows to other branches through normal git merge / PR workflow. +**Cross-worktree considerations (shared strategy):** +- Team root is outside the repo — in a git-backed squad repo clone or under platform app data. No repo writes needed. +- All clones of the same repo share one squad: same agents, charters, decisions, casting, and skills. +- Agent writes (history inbox, decisions inbox) go to the shared dir using the journal pattern (unique filenames, atomic creation, no contention across clones). +- Safe for concurrent sessions across clones. +- `TEAM_ROOT` passed to agents will be the external path. Agents don't need to know the mode. + **Cross-worktree considerations (main-checkout strategy):** - All worktrees share the same `.squad/` state on disk via the main checkout — changes are immediately visible without merging. - **Not safe for concurrent sessions.** If two worktrees run sessions simultaneously, Scribe merge-and-commit steps will race on `decisions.md` and git index. Use only when a single session is active at a time. diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index 3e9e3e184..f282a6b2b 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -102,6 +102,18 @@ "types": "./dist/resolution.d.ts", "import": "./dist/resolution.js" }, + "./shared-squad": { + "types": "./dist/shared-squad.d.ts", + "import": "./dist/shared-squad.js" + }, + "./clone-state": { + "types": "./dist/clone-state.d.ts", + "import": "./dist/clone-state.js" + }, + "./scribe-merge": { + "types": "./dist/scribe-merge.d.ts", + "import": "./dist/scribe-merge.js" + }, "./adapter/errors": { "types": "./dist/adapter/errors.d.ts", "import": "./dist/adapter/errors.js" diff --git a/packages/squad-sdk/src/clone-state.ts b/packages/squad-sdk/src/clone-state.ts new file mode 100644 index 000000000..81611e88e --- /dev/null +++ b/packages/squad-sdk/src/clone-state.ts @@ -0,0 +1,269 @@ +/** + * Clone-local runtime state resolution. + * + * Derives and manages per-clone state directories stored outside the repo + * working tree, under the platform-specific LOCAL app data directory. + * + * Layout: {localBase}/squad/repos/{repo-key}/clones/{leaf-name}/ + * + * Uses `validateRepoKey()` from shared-squad.ts for consistent validation. + * + * @module clone-state + */ + +import path from 'node:path'; +import os from 'node:os'; +import { FSStorageProvider } from './storage/fs-storage-provider.js'; +import { validateRepoKey } from './shared-squad.js'; +import { CASE_INSENSITIVE } from './resolution-base.js'; + +const storage = new FSStorageProvider(); + +/** + * Metadata stored in `clone.json` inside each clone-local state directory. + */ +export interface CloneStateMetadata { + clonePath: string; + repoKey: string; + firstSeen: string; + lastSeen: string; +} + +// ============================================================================ +// Platform-specific base directory +// ============================================================================ + +/** + * Return the platform-specific LOCAL app data base for squad. + * + * | Platform | Path | + * |----------|-----------------------------------------------| + * | Windows | `%LOCALAPPDATA%/squad/` | + * | macOS | `~/Library/Application Support/squad/` | + * | Linux | `$XDG_DATA_HOME/squad/` (default `~/.local/share/squad/`) | + * + * Unlike `resolveGlobalSquadPath()` (which uses ROAMING / XDG_CONFIG_HOME), + * this uses LOCAL / XDG_DATA_HOME — for high-write runtime state that must + * not traverse network shares. + */ +export function resolveLocalSquadBase(): string { + const platform = process.platform; + let base: string; + + if (platform === 'win32') { + base = process.env['LOCALAPPDATA'] + ?? path.join(os.homedir(), 'AppData', 'Local'); + } else if (platform === 'darwin') { + base = path.join(os.homedir(), 'Library', 'Application Support'); + } else { + // Linux / POSIX — XDG_DATA_HOME for local data (not XDG_CONFIG_HOME) + base = process.env['XDG_DATA_HOME'] ?? path.join(os.homedir(), '.local', 'share'); + } + + return path.join(base, 'squad'); +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +/** + * Normalize a clone path for consistent comparison and storage. + * Resolves to absolute, removes trailing separators, and lowercases + * on case-insensitive platforms (Windows, macOS). + */ +function normalizePath(clonePath: string): string { + let resolved = path.resolve(clonePath); + // Strip trailing separator (unless it's the root like "C:\") + while (resolved.length > 1 && resolved.endsWith(path.sep)) { + resolved = resolved.slice(0, -1); + } + if (CASE_INSENSITIVE) { + resolved = resolved.toLowerCase(); + } + return resolved; +} + +/** + * Read and parse a clone.json file. Returns null if missing or malformed. + */ +function readCloneJson(dir: string): CloneStateMetadata | null { + const jsonPath = path.join(dir, 'clone.json'); + const raw = storage.readSync(jsonPath); + if (!raw) return null; + try { + const parsed: unknown = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === 'object' && + typeof (parsed as Record)['clonePath'] === 'string' && + typeof (parsed as Record)['repoKey'] === 'string' && + typeof (parsed as Record)['firstSeen'] === 'string' && + typeof (parsed as Record)['lastSeen'] === 'string' + ) { + return parsed as CloneStateMetadata; + } + return null; + } catch { + return null; + } +} + +/** + * Compute the clones directory for a given repo key. + */ +function getClonesDir(repoKey: string): string { + const localBase = resolveLocalSquadBase(); + return path.join(localBase, 'repos', ...repoKey.split('/'), 'clones'); +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Derive the clone-local state directory path for a given clone. + * + * Path structure: `{localBase}/squad/repos/{repo-key}/clones/{leaf-name}/` + * + * `leaf-name` is the last path segment of `clonePath`, lowercased. + * On collision (two clones with the same leaf but different paths), + * suffixes `-2`, `-3`, etc. are appended. + * + * This function reads the filesystem to detect collisions but does NOT + * create any directories. + * + * @param clonePath - Absolute path to the clone's working tree. + * @param repoKey - Canonical repo key (e.g. "microsoft/os/os.2020"). + * @returns Absolute path to the clone-local state directory. + */ +export function resolveCloneStateDir(clonePath: string, repoKey: string): string { + validateRepoKey(repoKey); + const normalized = normalizePath(clonePath); + const leaf = path.basename(normalized).toLowerCase(); + if (!leaf || leaf === '.' || leaf === '..') { + throw new Error(`Cannot derive leaf name from clone path "${clonePath}".`); + } + + // If the leaf is a common generic name, prepend the parent dir to avoid collisions + // (e.g. D:\git\os\clone1\src → "clone1-src" instead of just "src") + const GENERIC_LEAVES = new Set(['src', 'source', 'repo', 'code', 'trunk', 'main', 'root']); + let effectiveLeaf = leaf; + if (GENERIC_LEAVES.has(leaf)) { + const parent = path.basename(path.dirname(normalized)).toLowerCase(); + if (parent && parent !== '.' && parent !== '..') { + effectiveLeaf = `${parent}-${leaf}`; + } + } + + const clonesDir = getClonesDir(repoKey); + + // First pass: scan ALL existing candidates (base leaf + suffixed) to check + // if this clonePath is already registered somewhere. + const baseCandidatePath = path.join(clonesDir, effectiveLeaf); + const existingMeta = readCloneJson(baseCandidatePath); + if (existingMeta && normalizePath(existingMeta.clonePath) === normalized) { + return baseCandidatePath; + } + + // Scan suffixed dirs + for (let i = 2; i <= 100; i++) { + const suffixedPath = path.join(clonesDir, `${effectiveLeaf}-${i}`); + if (!storage.existsSync(suffixedPath)) break; + const meta = readCloneJson(suffixedPath); + if (meta && normalizePath(meta.clonePath) === normalized) { + return suffixedPath; + } + } + + // Not registered yet — find the first available slot + if (!existingMeta || existingMeta === null) { + // Base slot is free (no clone.json or malformed) + if (!storage.existsSync(baseCandidatePath)) { + return baseCandidatePath; + } + // Dir exists but clone.json is missing/malformed — check if it's really empty + const meta = readCloneJson(baseCandidatePath); + if (!meta) { + return baseCandidatePath; + } + } + + // Base slot occupied by a different clone — find first free suffix + for (let i = 2; i <= 100; i++) { + const suffixedPath = path.join(clonesDir, `${effectiveLeaf}-${i}`); + if (!storage.existsSync(suffixedPath)) { + return suffixedPath; + } + const meta = readCloneJson(suffixedPath); + if (!meta) { + // Dir exists but clone.json missing/malformed — claim it + return suffixedPath; + } + // Occupied by yet another clone — continue + } + + throw new Error(`Clone leaf name collision limit exceeded for "${effectiveLeaf}" in repo "${repoKey}".`); +} + +/** + * Ensure the clone-local state directory exists and write/update `clone.json`. + * + * - Creates the directory (recursively) if it does not exist. + * - Writes `clone.json` with `{ clonePath, repoKey, firstSeen, lastSeen }`. + * - On subsequent calls, only updates `lastSeen`. + * + * Uses a claim-and-verify pattern: after resolving the target directory, + * re-checks clone.json to handle concurrent callers. + * + * @param clonePath - Absolute path to the clone's working tree. + * @param repoKey - Canonical repo key (e.g. "microsoft/os/os.2020"). + * @returns Absolute path to the clone-local state directory. + */ +export function ensureCloneState(clonePath: string, repoKey: string): string { + validateRepoKey(repoKey); + const normalized = normalizePath(clonePath); + const dir = resolveCloneStateDir(clonePath, repoKey); + const jsonPath = path.join(dir, 'clone.json'); + const now = new Date().toISOString(); + + // Ensure directory exists + if (!storage.existsSync(dir)) { + storage.mkdirSync(dir, { recursive: true }); + } + + // Re-read after mkdir to handle race with concurrent callers + const existing = readCloneJson(dir); + + if (existing && normalizePath(existing.clonePath) === normalized) { + // Already ours — update lastSeen + const updated: CloneStateMetadata = { ...existing, lastSeen: now }; + storage.writeSync(jsonPath, JSON.stringify(updated, null, 2) + '\n'); + return dir; + } + + if (existing && normalizePath(existing.clonePath) !== normalized) { + // Race condition: another caller claimed this slot between resolve and ensure. + // Re-resolve to find a new slot and retry once. + const retryDir = resolveCloneStateDir(clonePath, repoKey); + const retryJsonPath = path.join(retryDir, 'clone.json'); + if (!storage.existsSync(retryDir)) { + storage.mkdirSync(retryDir, { recursive: true }); + } + const retryExisting = readCloneJson(retryDir); + if (retryExisting && normalizePath(retryExisting.clonePath) === normalized) { + const updated: CloneStateMetadata = { ...retryExisting, lastSeen: now }; + storage.writeSync(retryJsonPath, JSON.stringify(updated, null, 2) + '\n'); + return retryDir; + } + // Claim the new slot + const meta: CloneStateMetadata = { clonePath: normalized, repoKey, firstSeen: now, lastSeen: now }; + storage.writeSync(retryJsonPath, JSON.stringify(meta, null, 2) + '\n'); + return retryDir; + } + + // No existing clone.json — claim this slot + const meta: CloneStateMetadata = { clonePath: normalized, repoKey, firstSeen: now, lastSeen: now }; + storage.writeSync(jsonPath, JSON.stringify(meta, null, 2) + '\n'); + return dir; +} diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 945b99dea..7d31407ce 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -10,8 +10,48 @@ const pkg = require('../package.json'); export const VERSION: string = pkg.version; // Export public API -export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, scratchDir, scratchFile, deriveProjectKey, resolveExternalStateDir } from './resolution.js'; -export type { SquadDirConfig, ResolvedSquadPaths } from './resolution.js'; +export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, scratchDir, scratchFile, deriveProjectKey, resolveExternalStateDir, resolveSquadPaths } from './resolution.js'; +export { ensureCloneState, resolveCloneStateDir } from './clone-state.js'; +export { normalizeRemoteUrl, getRemoteUrl } from './platform/detect.js'; +export type { NormalizedRemote } from './platform/detect.js'; +export type { CloneStateMetadata } from './clone-state.js'; +export type { SquadDirConfig } from './resolution.js'; +export type { ResolvedSquadPaths } from './resolution-base.js'; +export { + validateRepoKey, + /** @internal Used by CLI — not part of the public SDK API surface. */ + validateWritePath, + /** @internal Used by CLI — not part of the public SDK API surface. */ + sanitizeJournalFilenameComponent, + loadRepoRegistry, + saveRepoRegistry, + createSharedSquad, + createSharedSquadInRepo, + addSquadRepoPointer, + lookupByUrl, + lookupByUrlAcrossRepos, + lookupByKeyAcrossRepos, + /** @internal Used by CLI — not part of the public SDK API surface. */ + loadSquadRepoPointers, + resolveSharedSquad, + addUrlPattern, +} from './shared-squad.js'; +export type { + RepoRegistryEntry, + RepoRegistry, + SharedSquadManifest, + LocatedRegistryEntry, +} from './shared-squad.js'; +export { + /** @internal Used by CLI — not part of the public SDK API surface. */ + mergeInbox, + /** @internal Used by CLI — not part of the public SDK API surface. */ + recoverStaleProcessing, + mergeDecisionsInbox, + mergeAgentHistoryInbox, + mergeAllHistoryInboxes, +} from './scribe-merge.js'; +export type { MergeOptions, MergeResult } from './scribe-merge.js'; export * from './config/index.js'; export * from './agents/onboarding.js'; export { resolvePersonalAgents, mergeSessionCast } from './agents/personal.js'; diff --git a/packages/squad-sdk/src/platform/detect.ts b/packages/squad-sdk/src/platform/detect.ts index 1d351b3a7..b6039e37e 100644 --- a/packages/squad-sdk/src/platform/detect.ts +++ b/packages/squad-sdk/src/platform/detect.ts @@ -20,6 +20,16 @@ export interface AzureDevOpsRemoteInfo { repo: string; } +/** Normalized remote info for repo-keyed discovery */ +export interface NormalizedRemote { + provider: 'github' | 'azure-devops' | 'unknown'; + org: string; + project?: string; + repo: string; + key: string; + normalizedUrl: string; +} + /** * Parse a GitHub remote URL into owner/repo. * Supports HTTPS and SSH formats: @@ -138,3 +148,168 @@ export function getRemoteUrl(repoRoot: string): string | null { return null; } } + +/** + * Strip a trailing `.git` suffix from a string. + */ +function stripDotGit(s: string): string { + return s.endsWith('.git') ? s.slice(0, -4) : s; +} + +/** + * Normalize a git remote URL into a canonical repo identity. + * + * Pure function — no I/O. Handles GitHub HTTPS/SSH, Azure DevOps HTTPS + * (modern + legacy visualstudio.com), and Azure DevOps SSH. All keys are + * lowercased. `DefaultCollection/` is stripped from legacy ADO URLs. + * + * Returns a `NormalizedRemote` with `key` suitable for repo-keyed discovery + * and `normalizedUrl` for pattern matching. + */ +export function normalizeRemoteUrl(url: string): NormalizedRemote { + const trimmed = url.trim(); + + // ─── GitHub HTTPS: https://github.com/owner/repo[.git] ────────────── + const ghHttps = trimmed.match( + /^https?:\/\/(?:[^@]+@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i, + ); + if (ghHttps) { + const org = ghHttps[1]!.toLowerCase(); + const repo = ghHttps[2]!.toLowerCase(); + return { + provider: 'github', + org, + repo, + key: `${org}/${repo}`, + normalizedUrl: `github.com/${org}/${repo}`, + }; + } + + // ─── GitHub SSH (ssh:// form): ssh://[user@]github.com/owner/repo[.git] + const ghSshUrl = trimmed.match( + /^ssh:\/\/(?:[^@]+@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i, + ); + if (ghSshUrl) { + const org = ghSshUrl[1]!.toLowerCase(); + const repo = ghSshUrl[2]!.toLowerCase(); + return { + provider: 'github', + org, + repo, + key: `${org}/${repo}`, + normalizedUrl: `github.com/${org}/${repo}`, + }; + } + + // ─── GitHub SSH: git@github.com:owner/repo[.git] ──────────────────── + const ghSsh = trimmed.match( + /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?$/i, + ); + if (ghSsh) { + const org = ghSsh[1]!.toLowerCase(); + const repo = ghSsh[2]!.toLowerCase(); + return { + provider: 'github', + org, + repo, + key: `${org}/${repo}`, + normalizedUrl: `github.com/${org}/${repo}`, + }; + } + + // ─── ADO HTTPS modern: https://[user@]dev.azure.com/org/project/_git/repo[.git] ─ + const adoHttps = trimmed.match( + /^https?:\/\/(?:[^@]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/i, + ); + if (adoHttps) { + const org = adoHttps[1]!.toLowerCase(); + const project = adoHttps[2]!.toLowerCase(); + const repo = stripDotGit(adoHttps[3]!).toLowerCase(); + return { + provider: 'azure-devops', + org, + project, + repo, + key: `${org}/${project}/${repo}`, + normalizedUrl: `dev.azure.com/${org}/${project}/_git/${repo}`, + }; + } + + // ─── ADO SSH (ssh:// form): ssh://[user@]ssh.dev.azure.com/v3/org/project/repo[.git] + const adoSshUrl = trimmed.match( + /^ssh:\/\/(?:[^@]+@)?ssh\.dev\.azure\.com\/v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i, + ); + if (adoSshUrl) { + const org = adoSshUrl[1]!.toLowerCase(); + const project = adoSshUrl[2]!.toLowerCase(); + const repo = stripDotGit(adoSshUrl[3]!).toLowerCase(); + return { + provider: 'azure-devops', + org, + project, + repo, + key: `${org}/${project}/${repo}`, + normalizedUrl: `ssh.dev.azure.com/${org}/${project}/${repo}`, + }; + } + + // ─── ADO SSH: git@ssh.dev.azure.com:v3/org/project/repo[.git] ────── + const adoSsh = trimmed.match( + /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i, + ); + if (adoSsh) { + const org = adoSsh[1]!.toLowerCase(); + const project = adoSsh[2]!.toLowerCase(); + const repo = stripDotGit(adoSsh[3]!).toLowerCase(); + return { + provider: 'azure-devops', + org, + project, + repo, + key: `${org}/${project}/${repo}`, + normalizedUrl: `ssh.dev.azure.com/${org}/${project}/${repo}`, + }; + } + + // ─── ADO Legacy: https://org.visualstudio.com/[DefaultCollection/]project/_git/repo[.git] + const adoLegacy = trimmed.match( + /^https?:\/\/(?:[^@]+@)?([^/.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/i, + ); + if (adoLegacy) { + const org = adoLegacy[1]!.toLowerCase(); + const project = adoLegacy[2]!.toLowerCase(); + const repo = stripDotGit(adoLegacy[3]!).toLowerCase(); + return { + provider: 'azure-devops', + org, + project, + repo, + key: `${org}/${project}/${repo}`, + normalizedUrl: `${org}.visualstudio.com/${project}/_git/${repo}`, + }; + } + + // ─── Unknown provider — best-effort normalization ─────────────────── + let normalized = trimmed; + // Strip protocol + normalized = normalized.replace(/^(?:https?:\/\/|git@|ssh:\/\/)/, ''); + // Normalize SSH colon syntax + normalized = normalized.replace(/^([^/:]+):(.+)$/, '$1/$2'); + // Strip auth components + normalized = normalized.replace(/^[^@]+@/, ''); + // Strip trailing .git and slashes + normalized = stripDotGit(normalized).replace(/\/+$/, ''); + normalized = normalized.toLowerCase(); + + // Extract last path segment as repo name + const segments = normalized.split('/').filter(Boolean); + const repo = segments.length > 0 ? segments[segments.length - 1]! : ''; + + return { + provider: 'unknown', + org: '', + repo, + key: normalized, + normalizedUrl: normalized, + }; +} diff --git a/packages/squad-sdk/src/platform/index.ts b/packages/squad-sdk/src/platform/index.ts index a10c3af4a..608c1e690 100644 --- a/packages/squad-sdk/src/platform/index.ts +++ b/packages/squad-sdk/src/platform/index.ts @@ -5,8 +5,8 @@ */ export type { PlatformType, WorkItem, PullRequest, PlatformAdapter, WorkItemSource, HybridPlatformConfig, CommunicationChannel, CommunicationReply, CommunicationConfig, CommunicationAdapter } from './types.js'; -export type { GitHubRemoteInfo, AzureDevOpsRemoteInfo } from './detect.js'; -export { detectPlatform, detectPlatformFromUrl, detectWorkItemSource, parseGitHubRemote, parseAzureDevOpsRemote, getRemoteUrl } from './detect.js'; +export type { GitHubRemoteInfo, AzureDevOpsRemoteInfo, NormalizedRemote } from './detect.js'; +export { detectPlatform, detectPlatformFromUrl, detectWorkItemSource, parseGitHubRemote, parseAzureDevOpsRemote, getRemoteUrl, normalizeRemoteUrl } from './detect.js'; export { GitHubAdapter } from './github.js'; export { AzureDevOpsAdapter } from './azure-devops.js'; export type { AdoWorkItemConfig, WorkItemTypeInfo } from './azure-devops.js'; diff --git a/packages/squad-sdk/src/resolution-base.ts b/packages/squad-sdk/src/resolution-base.ts new file mode 100644 index 000000000..ef3a4ec9b --- /dev/null +++ b/packages/squad-sdk/src/resolution-base.ts @@ -0,0 +1,158 @@ +/** + * Resolution base — shared primitives for resolution.ts and shared-squad.ts. + * + * This module exists to break the circular dependency between resolution.ts + * and shared-squad.ts. It contains functions and types that both modules need + * but that have no dependencies on either module. + * + * @module resolution-base + */ + +import path from 'node:path'; +import os from 'node:os'; +import { FSStorageProvider } from './storage/fs-storage-provider.js'; + +const storage = new FSStorageProvider(); + +// ============================================================================ +// Case-insensitive path comparison +// ============================================================================ + +/** + * Whether the current platform uses case-insensitive path comparison. + * True on Windows and macOS (default HFS+/APFS). Set SQUAD_CASE_SENSITIVE=1 + * to override on case-sensitive macOS APFS configurations. + */ +export const CASE_INSENSITIVE = + !process.env['SQUAD_CASE_SENSITIVE'] && + (process.platform === 'win32' || process.platform === 'darwin'); + +/** + * Check if `fullPath` starts with `prefix`, respecting platform case sensitivity. + */ +export function pathStartsWith(fullPath: string, prefix: string): boolean { + if (CASE_INSENSITIVE) { + return fullPath.toLowerCase().startsWith(prefix.toLowerCase()); + } + return fullPath.startsWith(prefix); +} + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Schema for `.squad/config.json` — controls remote squad mode. + * Named SquadDirConfig to avoid collision with the runtime SquadConfig. + */ +export interface SquadDirConfig { + version: number; + teamRoot: string; + projectKey: string | null; + /** True when in consult mode (personal squad consulting on external project) */ + consult?: boolean; + /** True when extraction is disabled for consult sessions (read-only consultation) */ + extractionDisabled?: boolean; + /** Where state is stored: 'external' when moved out of the working tree */ + stateLocation?: string; + /** State storage backend: worktree | external | git-notes | orphan */ + stateBackend?: string; +} + +/** + * Resolved paths for dual-root squad mode. + * + * In **local** mode, projectDir and teamDir point to the same `.squad/` directory. + * In **remote** mode, config.json specifies a `teamRoot` that resolves to a + * separate directory for team identity (agents, casting, skills). + * In **shared** mode, the squad is discovered via origin remote URL lookup in + * `repos.json`. teamDir lives under the global app data directory's + * `squad/repos/{key}/` and projectDir is a clone-local state dir under the + * local app data directory (see `resolveLocalSquadBase()`). The clone + * working tree is never modified. + */ +export interface ResolvedSquadPaths { + mode: 'local' | 'remote' | 'shared'; + /** Project-local .squad/ (decisions, logs) */ + projectDir: string; + /** Team identity root (agents, casting, skills) */ + teamDir: string; + /** User's personal squad dir, null if not found or disabled */ + personalDir: string | null; + config: SquadDirConfig | null; + name: '.squad' | '.ai-team'; + isLegacy: boolean; +} + +// ============================================================================ +// Global path resolution +// ============================================================================ + +/** + * Return the platform-specific global Squad configuration directory. + * + * | Platform | Path | + * |----------|--------------------------------------------| + * | Windows | `%APPDATA%/squad/` | + * | macOS | `~/Library/Application Support/squad/` | + * | Linux | `$XDG_CONFIG_HOME/squad/` (default `~/.config/squad/`) | + * + * The directory is created (recursively) if it does not already exist. + * + * @returns Absolute path to the global squad config directory. + */ +export function resolveGlobalSquadPath(): string { + // SQUAD_APPDATA_OVERRIDE: escape hatch for offline roaming profiles (F11). + // When %APPDATA% is unreachable (e.g. network share down), users can point + // all global squad storage at an accessible local path. + const appdataOverride = process.env['SQUAD_APPDATA_OVERRIDE']; + if (appdataOverride) { + const globalDir = path.join(appdataOverride, 'squad'); + if (!storage.existsSync(globalDir)) { + storage.mkdirSync(globalDir, { recursive: true }); + } + return globalDir; + } + + const platform = process.platform; + let base: string; + + if (platform === 'win32') { + // %APPDATA% is always set on Windows; fall back to %LOCALAPPDATA%, then homedir + base = process.env['APPDATA'] + ?? process.env['LOCALAPPDATA'] + ?? path.join(os.homedir(), 'AppData', 'Roaming'); + } else if (platform === 'darwin') { + base = path.join(os.homedir(), 'Library', 'Application Support'); + } else { + // Linux / other POSIX — respect XDG_CONFIG_HOME + base = process.env['XDG_CONFIG_HOME'] ?? path.join(os.homedir(), '.config'); + } + + const globalDir = path.join(base, 'squad'); + + if (!storage.existsSync(globalDir)) { + storage.mkdirSync(globalDir, { recursive: true }); + } + + return globalDir; +} + +/** + * Resolves the user's personal squad directory. + * Returns null if SQUAD_NO_PERSONAL is set or directory doesn't exist. + * + * Platform paths: + * - Windows: %APPDATA%/squad/personal-squad + * - macOS: ~/Library/Application Support/squad/personal-squad + * - Linux: $XDG_CONFIG_HOME/squad/personal-squad or ~/.config/squad/personal-squad + */ +export function resolvePersonalSquadDir(): string | null { + if (process.env['SQUAD_NO_PERSONAL']) return null; + + const globalDir = resolveGlobalSquadPath(); + const personalDir = path.join(globalDir, 'personal-squad'); + + if (!storage.existsSync(personalDir)) return null; + return personalDir; +} diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index fa4bf8324..d6bbd678b 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -9,58 +9,38 @@ * PR bradygaster/squad#131. Original concept: resolveSquadPaths() with config.json * pointer for team identity separation. * + * Note on circular import with shared-squad.ts: + * resolution.ts imports { resolveSharedSquad, lookupByKeyAcrossRepos, validateRepoKey } + * from shared-squad.ts, which imports { resolveGlobalSquadPath, resolvePersonalSquadDir } + * from resolution.ts. This cycle is safe because all cross-module references are to + * hoisted function declarations (never used at module evaluation time). Both modules' + * top-level code (const storage = ...) uses only their own local imports. + * * @module resolution */ import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; +import { realpathSync } from 'node:fs'; import { FSStorageProvider } from './storage/fs-storage-provider.js'; +import { SquadError, ErrorSeverity, ErrorCategory } from './adapter/errors.js'; +import { resolveSharedSquad, lookupByKeyAcrossRepos, validateRepoKey } from './shared-squad.js'; +import { resolveCloneStateDir } from './clone-state.js'; +import { + resolveGlobalSquadPath, + resolvePersonalSquadDir, + pathStartsWith, + CASE_INSENSITIVE, +} from './resolution-base.js'; +import type { SquadDirConfig, ResolvedSquadPaths } from './resolution-base.js'; + +// Re-export shared primitives from resolution-base for backward compatibility +export { resolveGlobalSquadPath, resolvePersonalSquadDir, CASE_INSENSITIVE, pathStartsWith }; +export type { SquadDirConfig, ResolvedSquadPaths }; const storage = new FSStorageProvider(); -// ============================================================================ -// Dual-root path resolution types (Issue #311) -// ============================================================================ - -/** - * Schema for `.squad/config.json` — controls remote squad mode. - * Named SquadDirConfig to avoid collision with the runtime SquadConfig. - */ -export interface SquadDirConfig { - version: number; - teamRoot: string; - projectKey: string | null; - /** True when in consult mode (personal squad consulting on external project) */ - consult?: boolean; - /** True when extraction is disabled for consult sessions (read-only consultation) */ - extractionDisabled?: boolean; - /** Where state is stored: 'external' when moved out of the working tree */ - stateLocation?: string; - /** State storage backend: worktree | external | git-notes | orphan */ - stateBackend?: string; -} - -/** - * Resolved paths for dual-root squad mode. - * - * In **local** mode, projectDir and teamDir point to the same `.squad/` directory. - * In **remote** mode, config.json specifies a `teamRoot` that resolves to a - * separate directory for team identity (agents, casting, skills). - */ -export interface ResolvedSquadPaths { - mode: 'local' | 'remote'; - /** Project-local .squad/ (decisions, logs) */ - projectDir: string; - /** Team identity root (agents, casting, skills) */ - teamDir: string; - /** User's personal squad dir, null if not found or disabled */ - personalDir: string | null; - config: SquadDirConfig | null; - name: '.squad' | '.ai-team'; - isLegacy: boolean; -} - /** * Given a directory containing a `.git` worktree pointer file, parse the file * to derive the absolute path of the main checkout. @@ -116,7 +96,14 @@ export function resolveSquad(startDir?: string): string | null { const candidate = path.join(current, '.squad'); if (storage.existsSync(candidate) && storage.isDirectorySync(candidate)) { - return candidate; + // Validate this is a real squad team root, not just a config directory + // (e.g. ~/.squad/ which only contains squad-repos.json pointer files). + const hasTeam = storage.existsSync(path.join(candidate, 'team.md')); + const hasAgents = storage.existsSync(path.join(candidate, 'agents')); + const hasConfig = storage.existsSync(path.join(candidate, 'config.json')); + if (hasTeam || hasAgents || hasConfig) { + return candidate; + } } const gitMarker = path.join(current, '.git'); @@ -172,7 +159,13 @@ function findSquadDir(startDir: string): { dir: string; name: '.squad' | '.ai-te for (const name of SQUAD_DIR_NAMES) { const candidate = path.join(current, name); if (storage.existsSync(candidate) && storage.isDirectorySync(candidate)) { - return { dir: candidate, name }; + // Validate this is a real squad team root, not just a config directory + const hasTeam = storage.existsSync(path.join(candidate, 'team.md')); + const hasAgents = storage.existsSync(path.join(candidate, 'agents')); + const hasConfig = storage.existsSync(path.join(candidate, 'config.json')); + if (hasTeam || hasAgents || hasConfig) { + return { dir: candidate, name }; + } } } @@ -256,23 +249,35 @@ export function isConsultMode(config: SquadDirConfig | null): boolean { * @returns Resolved paths, or `null` if no squad directory is found. */ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null { - const resolved = findSquadDir(startDir ?? process.cwd()); - if (!resolved) { - return null; - } - - const { dir: projectDir, name } = resolved; - const isLegacy = name === '.ai-team'; - const config = loadDirConfig(projectDir); + const start = startDir ?? process.cwd(); + const resolved = findSquadDir(start); + + // Step 1-2: Local or remote mode (existing behavior — unchanged) + if (resolved) { + const { dir: projectDir, name } = resolved; + const isLegacy = name === '.ai-team'; + const config = loadDirConfig(projectDir); + + if (config && config.teamRoot) { + // Remote mode: teamDir resolved relative to the project root (parent of .squad/) + const projectRoot = path.resolve(projectDir, '..'); + const teamDir = path.resolve(projectRoot, config.teamRoot); + return { + mode: 'remote', + projectDir, + teamDir, + personalDir: resolvePersonalSquadDir(), + config, + name, + isLegacy, + }; + } - if (config && config.teamRoot) { - // Remote mode: teamDir resolved relative to the project root (parent of .squad/) - const projectRoot = path.resolve(projectDir, '..'); - const teamDir = path.resolve(projectRoot, config.teamRoot); + // Local mode: projectDir === teamDir return { - mode: 'remote', + mode: 'local', projectDir, - teamDir, + teamDir: projectDir, personalDir: resolvePersonalSquadDir(), config, name, @@ -280,73 +285,141 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null }; } - // Local mode: projectDir === teamDir - return { - mode: 'local', - projectDir, - teamDir: projectDir, - personalDir: resolvePersonalSquadDir(), - config, - name, - isLegacy, - }; + // Step 3: Shared squad discovery (no local .squad/ found) + return resolveSharedMode(start); +} + +// ============================================================================ +// Shared mode resolution (Issue #311 — shared-squad-across-clones) +// ============================================================================ + +/** + * Walk up the directory tree to find the git repository root. + * Returns the directory that contains `.git` (as a directory or file). + */ +function findGitRoot(startDir: string): string | null { + let current = path.resolve(startDir); + // eslint-disable-next-line no-constant-condition + while (true) { + const gitMarker = path.join(current, '.git'); + if (storage.existsSync(gitMarker)) { + return current; + } + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +let _appdataOverrideWarned = false; + +/** @internal Reset the warn-once flag — for testing only. */ +export function _resetAppdataOverrideWarned(): void { + _appdataOverrideWarned = false; } /** - * Return the platform-specific global Squad configuration directory. + * Shared mode resolution — discovers squad via origin remote URL lookup + * or explicit SQUAD_REPO_KEY environment variable. * - * | Platform | Path | - * |----------|--------------------------------------------| - * | Windows | `%APPDATA%/squad/` | - * | macOS | `~/Library/Application Support/squad/` | - * | Linux | `$XDG_CONFIG_HOME/squad/` (default `~/.config/squad/`) | + * Called by resolveSquadPaths() as step 3 when no local `.squad/` is found. * - * The directory is created (recursively) if it does not already exist. + * Supports two environment variables: + * - `SQUAD_REPO_KEY`: Direct repo key for registry lookup (skips URL matching). + * Useful in CI or for repos without an `origin` remote. + * - `SQUAD_APPDATA_OVERRIDE`: Override the global app data path. Logged as a + * warning (once per process). Used when `%APPDATA%` is unreachable + * (offline roaming profile). * - * @returns Absolute path to the global squad config directory. + * @throws {SquadError} If `%APPDATA%` (or override) is unreachable (F11). */ -export function resolveGlobalSquadPath(): string { - const platform = process.platform; - let base: string; - - if (platform === 'win32') { - // %APPDATA% is always set on Windows; fall back to %LOCALAPPDATA%, then homedir - base = process.env['APPDATA'] - ?? process.env['LOCALAPPDATA'] - ?? path.join(os.homedir(), 'AppData', 'Roaming'); - } else if (platform === 'darwin') { - base = path.join(os.homedir(), 'Library', 'Application Support'); - } else { - // Linux / other POSIX — respect XDG_CONFIG_HOME - base = process.env['XDG_CONFIG_HOME'] ?? path.join(os.homedir(), '.config'); +function resolveSharedMode(startDir: string): ResolvedSquadPaths | null { + const repoRoot = findGitRoot(startDir); + if (!repoRoot) return null; + + // SQUAD_APPDATA_OVERRIDE: log once per process when entering shared discovery + if (process.env['SQUAD_APPDATA_OVERRIDE'] && !_appdataOverrideWarned) { + console.warn( + '[squad] SQUAD_APPDATA_OVERRIDE is set — using override path for app data.' + ); + _appdataOverrideWarned = true; } - const globalDir = path.join(base, 'squad'); + // Verify global squad path is accessible (F11: fail hard if unreachable) + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + } catch (err) { + throw new SquadError( + 'Shared squad unavailable — roaming profile may be offline. ' + + 'Hint: check network connectivity or set SQUAD_APPDATA_OVERRIDE env var.', + ErrorSeverity.ERROR, + ErrorCategory.CONFIGURATION, + { operation: 'resolveSquadPaths', timestamp: new Date() }, + false, + err instanceof Error ? err : undefined, + ); + } - if (!storage.existsSync(globalDir)) { - storage.mkdirSync(globalDir, { recursive: true }); + // SQUAD_REPO_KEY — direct key lookup, skips URL matching + const repoKey = process.env['SQUAD_REPO_KEY']; + if (repoKey) { + validateRepoKey(repoKey); + return resolveSharedByKey(repoKey, repoRoot, globalDir); } - return globalDir; + // URL-based discovery via origin remote (F4: origin only) + return resolveSharedSquad(repoRoot); } /** - * Resolves the user's personal squad directory. - * Returns null if SQUAD_NO_PERSONAL is set or directory doesn't exist. - * - * Platform paths: - * - Windows: %APPDATA%/squad/personal-squad - * - macOS: ~/Library/Application Support/squad/personal-squad - * - Linux: $XDG_CONFIG_HOME/squad/personal-squad or ~/.config/squad/personal-squad + * Resolve shared squad paths by explicit repo key. + * Looks up the key in the global registry, derives teamDir and projectDir. */ -export function resolvePersonalSquadDir(): string | null { - if (process.env['SQUAD_NO_PERSONAL']) return null; - - const globalDir = resolveGlobalSquadPath(); - const personalDir = path.join(globalDir, 'personal-squad'); - - if (!storage.existsSync(personalDir)) return null; - return personalDir; +function resolveSharedByKey( + repoKey: string, + repoRoot: string, + globalDir: string, +): ResolvedSquadPaths | null { + const located = lookupByKeyAcrossRepos(repoKey); + if (!located) return null; + + const { entry, squadRepoRoot } = located; + + // For git-backed repos: {squadRepoRoot}/{key} (files live directly in the clone) + // For legacy %APPDATA%: {squadRepoRoot}/repos/{key} + const isLegacyAppData = squadRepoRoot === globalDir; + const teamDir = isLegacyAppData + ? path.join(squadRepoRoot, 'repos', ...entry.key.split('/')) + : path.join(squadRepoRoot, ...entry.key.split('/')); + + // Validate teamDir with realpathSync (same check as resolveSharedSquad — F7) + try { + if (storage.existsSync(teamDir)) { + const realTeamDir = realpathSync(teamDir); + const realRoot = realpathSync(squadRepoRoot); + if ( + !pathStartsWith(realTeamDir, realRoot + path.sep) && + realTeamDir !== realRoot + ) { + return null; + } + } + } catch { + return null; + } + + const projectDir = resolveCloneStateDir(repoRoot, entry.key); + + return { + mode: 'shared', + projectDir, + teamDir, + personalDir: resolvePersonalSquadDir(), + config: null, + name: '.squad', + isLegacy: false, + }; } /** diff --git a/packages/squad-sdk/src/scribe-merge.ts b/packages/squad-sdk/src/scribe-merge.ts new file mode 100644 index 000000000..f1d08a1a6 --- /dev/null +++ b/packages/squad-sdk/src/scribe-merge.ts @@ -0,0 +1,411 @@ +/** + * Scribe Inbox Merge — claim protocol for concurrent-safe inbox merging. + * + * Implements the Scribe Claim Protocol from the shared-squad-across-clones + * design: atomic rename from inbox/ → processing/, merge into canonical + * file with content-hash deduplication, crash recovery for stale + * processing/ entries. + * + * @module scribe-merge + */ + +import { createHash, randomBytes } from 'node:crypto'; +import path from 'node:path'; +import type { StorageProvider } from './storage/storage-provider.js'; +import { FSStorageProvider } from './storage/fs-storage-provider.js'; +import type { ResolvedSquadPaths } from './resolution-base.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface MergeOptions { + /** If true, return what would be merged without writing. */ + dryRun?: boolean; +} + +export interface MergeResult { + /** Number of entries successfully merged into the canonical file. */ + merged: number; + /** Number of entries skipped (already present via dedup). */ + skipped: number; + /** Non-fatal errors encountered during processing. */ + errors: string[]; +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +/** Hash a trimmed string with SHA-256 → hex. */ +function contentHash(text: string): string { + return createHash('sha256').update(text.trim()).digest('hex'); +} + +/** + * Extract an ISO-style timestamp from a journal filename. + * + * Expected format: `{agent}-{ISO-timestamp}-{8hex}.md` + * Example: `flight-2025-07-22T10-05-00Z-a1b2c3d4.md` + * + * The timestamp portion uses hyphens instead of colons (filename-safe). + * Falls back to epoch 0 if parsing fails — entries with unparseable + * timestamps sort to the front rather than being dropped. + */ +function extractTimestamp(filename: string): Date { + // Strip .md extension + const base = filename.replace(/\.md$/i, ''); + // Match ISO-like timestamp: YYYY-MM-DDTHH-MM-SSZ or similar + const match = base.match( + /(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}(?:-\d{2})?Z)/, + ); + if (!match) return new Date(0); + // Restore colons: 2025-07-22T10-05-00Z → 2025-07-22T10:05:00Z + const parts = match[1]!.split('T'); + if (parts.length !== 2) return new Date(0); + const timePart = parts[1]!; + const segments = timePart.replace(/Z$/, '').split('-'); + const restored = + parts[0] + 'T' + segments.join(':') + 'Z'; + const d = new Date(restored); + return isNaN(d.getTime()) ? new Date(0) : d; +} + +/** + * Split a canonical markdown file into individual entry blocks. + * + * Entries are delimited by `### ` headings at the start of a line. + * Returns trimmed content strings (heading included). + */ +function splitEntries(content: string): string[] { + if (!content.trim()) return []; + const blocks: string[] = []; + const lines = content.split(/\r?\n/); + let current: string[] = []; + let pendingSection: string[] = []; + + for (const line of lines) { + // A ## header (not ###) starts a section — buffer it to attach to the next ### entry + if (line.startsWith('## ') && !line.startsWith('### ')) { + // Flush any in-progress entry + if (current.length > 0) { + const trimmed = current.join('\n').trim(); + if (trimmed) blocks.push(trimmed); + current = []; + } + pendingSection = [line]; + continue; + } + + // A ### header starts a new entry — attach any pending ## section header + if (line.startsWith('### ')) { + if (current.length > 0) { + const trimmed = current.join('\n').trim(); + if (trimmed) blocks.push(trimmed); + } + current = pendingSection.length > 0 ? [...pendingSection, line] : [line]; + pendingSection = []; + continue; + } + + // Accumulate into pending section or current entry + if (pendingSection.length > 0) { + pendingSection.push(line); + } else { + current.push(line); + } + } + + // Flush remaining + if (pendingSection.length > 0) { + const trimmed = pendingSection.join('\n').trim(); + if (trimmed) blocks.push(trimmed); + } + if (current.length > 0) { + const trimmed = current.join('\n').trim(); + if (trimmed) blocks.push(trimmed); + } + return blocks; +} + +/** Build a Set of content hashes from existing canonical entries. */ +function buildDedupSet(canonicalContent: string): Set { + const entries = splitEntries(canonicalContent); + const hashes = new Set(); + for (const entry of entries) { + hashes.add(contentHash(entry)); + } + return hashes; +} + +/** Safely list a directory, returning [] if it doesn't exist. */ +function safeListSync(dir: string, storage: StorageProvider): string[] { + return storage.listSync(dir); +} + +/** Safely read a file, returning '' if it doesn't exist. */ +function safeReadSync(filePath: string, storage: StorageProvider): string { + return storage.readSync(filePath) ?? ''; +} + +// --------------------------------------------------------------------------- +// Core merge +// --------------------------------------------------------------------------- + +/** + * Merge all `.md` files from an inbox directory into a canonical file + * using the Scribe Claim Protocol. + * + * Protocol: + * 1. List `.md` files in `inboxDir` + * 2. Atomically rename each to `processing/` (sibling of inbox) + * 3. Read ALL files in `processing/` (includes crash-recovered entries) + * 4. Sort by timestamp extracted from filename + * 5. Read existing canonical file content + * 6. Append new entries, deduplicating by content hash + * 7. Write merged result via atomic temp+rename + * 8. Delete processed files + * 9. Remove processing/ if empty + */ +export function mergeInbox( + inboxDir: string, + canonicalFile: string, + options?: MergeOptions, + storage: StorageProvider = new FSStorageProvider(), +): MergeResult { + const result: MergeResult = { merged: 0, skipped: 0, errors: [] }; + const processingDir = path.join(path.dirname(inboxDir), 'processing'); + + // Step 1: List inbox .md files + const inboxFiles = safeListSync(inboxDir, storage).filter((f) => + f.endsWith('.md'), + ); + + // Step 2: Claim — atomic rename to processing/ + if (inboxFiles.length > 0) { + storage.mkdirSync(processingDir, { recursive: true }); + } + for (const file of inboxFiles) { + const src = path.join(inboxDir, file); + const dest = path.join(processingDir, file); + try { + storage.renameSync(src, dest); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + // Another Scribe claimed this file — skip silently + continue; + } + result.errors.push(`claim ${file}: ${(err as Error).message}`); + } + } + + // Step 3: Read ALL files in processing/ (claimed + pre-existing from crashes) + const processingFiles = safeListSync(processingDir, storage).filter((f) => + f.endsWith('.md'), + ); + if (processingFiles.length === 0) { + return result; + } + + // Step 4: Sort by timestamp from filename + const sorted = [...processingFiles].sort((a, b) => { + return extractTimestamp(a).getTime() - extractTimestamp(b).getTime(); + }); + + // Step 5: Read existing canonical + build dedup set + const existingContent = safeReadSync(canonicalFile, storage); + const dedupSet = buildDedupSet(existingContent); + + // Step 6: Collect new entries (dedup by content hash) + const newEntries: string[] = []; + const processedFiles: string[] = []; + + for (const file of sorted) { + const filePath = path.join(processingDir, file); + try { + const raw = storage.readSync(filePath); + if (raw === undefined) { + result.errors.push(`read ${file}: file not found`); + continue; + } + const content = raw.trim(); + if (!content) { + processedFiles.push(file); + result.skipped++; + continue; + } + const hash = contentHash(content); + if (dedupSet.has(hash)) { + // Already in canonical — skip (idempotent) + processedFiles.push(file); + result.skipped++; + } else { + dedupSet.add(hash); + newEntries.push(content); + processedFiles.push(file); + result.merged++; + } + } catch (err: unknown) { + result.errors.push(`read ${file}: ${(err as Error).message}`); + } + } + + // Step 7: Write merged result via atomic temp+rename + if (newEntries.length > 0 && !options?.dryRun) { + const separator = existingContent.trim() ? '\n\n' : ''; + const merged = existingContent.trimEnd() + separator + newEntries.join('\n\n') + '\n'; + const tmpFile = + canonicalFile + '.tmp.' + randomBytes(4).toString('hex'); + storage.mkdirSync(path.dirname(canonicalFile), { recursive: true }); + storage.writeSync(tmpFile, merged); + storage.renameSync(tmpFile, canonicalFile); + } + + // Step 8: Delete processed files from processing/ + if (!options?.dryRun) { + for (const file of processedFiles) { + try { + storage.deleteSync(path.join(processingDir, file)); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + result.errors.push(`delete ${file}: ${(err as Error).message}`); + } + } + } + + // Step 9: Remove processing/ if empty (non-recursive rmdir semantics) + try { + const remaining = storage.listSync(processingDir); + if (remaining.length === 0) { + storage.deleteDirSync(processingDir); + } + } catch { + // Not empty or already gone — fine + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Recovery +// --------------------------------------------------------------------------- + +/** + * Recover stale files from `processing/` by moving them back to `inbox/`. + * + * A file is considered stale if its mtime is older than `maxAgeMinutes`. + * This handles the case where a Scribe crashed after claiming files but + * before completing the merge. + * + * @returns Count of recovered files. + */ +export function recoverStaleProcessing( + processingDir: string, + maxAgeMinutes = 5, + storage: StorageProvider = new FSStorageProvider(), +): number { + const files = safeListSync(processingDir, storage).filter((f) => + f.endsWith('.md'), + ); + if (files.length === 0) return 0; + + const inboxDir = path.join(path.dirname(processingDir), 'inbox'); + const cutoff = maxAgeMinutes <= 0 + ? Infinity // 0 or negative = treat everything as stale + : Date.now() - maxAgeMinutes * 60_000; + let recovered = 0; + + for (const file of files) { + const filePath = path.join(processingDir, file); + try { + const st = storage.statSync(filePath); + if (!st) continue; // File disappeared — skip + if (st.mtimeMs < cutoff) { + storage.mkdirSync(inboxDir, { recursive: true }); + storage.renameSync(filePath, path.join(inboxDir, file)); + recovered++; + } + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + // File already moved — skip + } + } + + return recovered; +} + +// --------------------------------------------------------------------------- +// Convenience wrappers +// --------------------------------------------------------------------------- + +/** + * Merge the decisions inbox into `decisions.md`. + */ +export function mergeDecisionsInbox( + paths: ResolvedSquadPaths, + options?: MergeOptions, + storage: StorageProvider = new FSStorageProvider(), +): MergeResult { + return mergeInbox( + path.join(paths.teamDir, 'decisions', 'inbox'), + path.join(paths.teamDir, 'decisions.md'), + options, + storage, + ); +} + +/** + * Merge a single agent's history inbox into `history.md`. + */ +export function mergeAgentHistoryInbox( + paths: ResolvedSquadPaths, + agentName: string, + options?: MergeOptions, + storage: StorageProvider = new FSStorageProvider(), +): MergeResult { + return mergeInbox( + path.join(paths.teamDir, 'agents', agentName, 'history', 'inbox'), + path.join(paths.teamDir, 'agents', agentName, 'history.md'), + options, + storage, + ); +} + +/** + * Merge ALL agent history inboxes. + * + * Scans the `agents/` directory for subdirectories that contain a + * `history/inbox/` folder, and merges each one. + */ +export function mergeAllHistoryInboxes( + paths: ResolvedSquadPaths, + options?: MergeOptions, + storage: StorageProvider = new FSStorageProvider(), +): Map { + const results = new Map(); + const agentsDir = path.join(paths.teamDir, 'agents'); + const agents = safeListSync(agentsDir, storage); + + for (const agent of agents) { + const inboxPath = path.join(agentsDir, agent, 'history', 'inbox'); + // Only merge if the inbox directory exists + if (storage.existsSync(inboxPath)) { + try { + const r = mergeAgentHistoryInbox(paths, agent, options, storage); + results.set(agent, r); + } catch (err: unknown) { + results.set(agent, { + merged: 0, + skipped: 0, + errors: [(err as Error).message], + }); + } + } + } + + return results; +} diff --git a/packages/squad-sdk/src/shared-squad.ts b/packages/squad-sdk/src/shared-squad.ts new file mode 100644 index 000000000..43698c597 --- /dev/null +++ b/packages/squad-sdk/src/shared-squad.ts @@ -0,0 +1,760 @@ +/** + * Shared Squad — Input validation for repo keys and write paths. + * + * Repo keys (e.g. `microsoft/os.2020` or `microsoft/os/os.2020`) map directly + * to nested directories under `%APPDATA%/squad/repos/`. Without validation, + * a malicious key like `../../etc/passwd` would escape the repos directory. + * + * These guards are the first line of defense — called at `squad init --shared`, + * SQUAD_REPO_KEY env var parsing, and registry deserialization. + * + * Security findings addressed: + * - F1 (BLOCKING): Path traversal via unsanitized repo key + * - F5 (IMPORTANT): Agent name injection in journal filenames + * - F7 (IMPORTANT): Symlink/junction redirect attacks on write paths + * + * @module shared-squad + */ + +import path from 'node:path'; +import os from 'node:os'; +import { realpathSync } from 'node:fs'; +import { FSStorageProvider } from './storage/fs-storage-provider.js'; +import { resolveGlobalSquadPath, resolvePersonalSquadDir, pathStartsWith, CASE_INSENSITIVE } from './resolution-base.js'; +import type { ResolvedSquadPaths } from './resolution-base.js'; +import { normalizeRemoteUrl, getRemoteUrl } from './platform/detect.js'; +import { resolveCloneStateDir } from './clone-state.js'; + +const storage = new FSStorageProvider(); + +/** Allowed characters per segment: lowercase alphanumeric, dot, underscore, hyphen. */ +const SEGMENT_PATTERN = /^[a-z0-9._-]+$/; + +/** Maximum length for a single segment (prevents filesystem path length issues). */ +const MAX_SEGMENT_LENGTH = 128; + +/** Windows-illegal filename characters (also rejected on all platforms for portability). */ +const WINDOWS_ILLEGAL_CHARS = /[<>:"|?*\\]/; + +/** + * Validate a repo key before it's used to derive a filesystem path. + * + * A valid key has 2 segments (`owner/repo`) or 3 segments (`org/project/repo`), + * each containing only lowercase alphanumeric chars, dots, underscores, or hyphens. + * + * @param key - The repo key to validate (e.g. `microsoft/os.2020`). + * @throws {Error} If the key is invalid with a descriptive message. + */ +export function validateRepoKey(key: string): void { + // Null byte check — must come first since null bytes can bypass downstream checks + if (key.includes('\0')) { + throw new Error(`Invalid repo key: contains null byte`); + } + + // Empty string + if (key.length === 0) { + throw new Error(`Invalid repo key: empty string`); + } + + // Absolute path prefixes (Unix, Windows drive, UNC) + if (key.startsWith('/') || key.startsWith('\\') || /^[a-zA-Z]:/.test(key)) { + throw new Error(`Invalid repo key "${key}": absolute paths are not allowed`); + } + + // Windows-illegal filename characters (checked on all platforms for portability) + if (WINDOWS_ILLEGAL_CHARS.test(key)) { + throw new Error( + `Invalid repo key "${key}": contains illegal characters (< > : " | ? * \\)` + ); + } + + const segments = key.split('/'); + + // Path traversal — reject segments that are exactly '.' or '..' + if (segments.some(s => s === '.' || s === '..')) { + throw new Error(`Invalid repo key "${key}": path traversal (. or ..) rejected`); + } + + // Segment count: must be 2 (owner/repo) or 3 (org/project/repo) + if (segments.length < 2 || segments.length > 3) { + throw new Error( + `Invalid repo key "${key}": must have 2-3 segments (owner/repo or org/project/repo)` + ); + } + + for (const seg of segments) { + if (seg === '') { + throw new Error(`Invalid repo key "${key}": empty segment`); + } + if (seg.length > MAX_SEGMENT_LENGTH) { + throw new Error( + `Invalid repo key "${key}": segment "${seg.slice(0, 20)}..." exceeds ${MAX_SEGMENT_LENGTH} character limit` + ); + } + if (!SEGMENT_PATTERN.test(seg)) { + throw new Error( + `Invalid repo key "${key}": segment "${seg}" contains invalid characters (allowed: a-z 0-9 . _ -)` + ); + } + } +} + +/** + * Verify that a resolved path stays under the expected root directory. + * + * Uses `fs.realpathSync()` on the nearest existing ancestor of `resolvedPath` + * and on `expectedRoot` to catch symlink/junction redirect attacks. This is + * safe to call even when the target file doesn't exist yet — it walks up to + * the nearest existing ancestor directory. + * + * @param resolvedPath - The target path to validate (may not exist yet). + * @param expectedRoot - The directory the path must stay inside (must exist). + * @throws {Error} If the path escapes the expected root. + */ +export function validateWritePath(resolvedPath: string, expectedRoot: string): void { + const resolvedTarget = path.resolve(resolvedPath); + let resolvedRoot: string; + + try { + resolvedRoot = realpathSync(path.resolve(expectedRoot)); + } catch { + throw new Error( + `Write path validation failed: expected root "${expectedRoot}" does not exist or is inaccessible` + ); + } + + if (!storage.isDirectorySync(resolvedRoot)) { + throw new Error(`Write path validation failed: expected root "${expectedRoot}" is not a directory`); + } + + // Walk up from the target to find the nearest existing ancestor + let checkPath = resolvedTarget; + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const realAncestor = realpathSync(checkPath); + if (!pathStartsWith(realAncestor, resolvedRoot + path.sep) && realAncestor !== resolvedRoot) { + throw new Error( + `Write path escapes expected root: resolved path is outside "${resolvedRoot}"` + ); + } + // Ancestor is inside root — the remaining path segments are safe + // (they can't escape via symlink since they don't exist yet) + return; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + const parent = path.dirname(checkPath); + if (parent === checkPath) { + // Reached filesystem root without finding an existing ancestor + throw new Error( + `Write path escapes expected root: no existing ancestor found under "${resolvedRoot}"` + ); + } + checkPath = parent; + continue; + } + throw err; + } + } +} + +/** + * Sanitize a name for use as a component in journal filenames. + * + * Journal filenames follow the pattern `{agent-name}-{timestamp}-{random}.md`. + * If an agent name contains path separators or other special characters, it + * could be used to inject path traversal into the filename. + * + * Replaces any character outside `[a-zA-Z0-9_-]` with `_`. + * + * @param name - The raw agent or component name. + * @returns A sanitized string safe for use in filenames. + */ +export function sanitizeJournalFilenameComponent(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +// ============================================================================ +// Repo Registry Types +// ============================================================================ + +/** A single entry in repos.json — key-only, paths derived from key (F7). */ +export interface RepoRegistryEntry { + /** Canonical repo key (e.g. "microsoft/os/os.2020" or "owner/repo"). */ + key: string; + /** Normalized URL patterns for matching clones to this entry. */ + urlPatterns: string[]; + /** ISO-8601 timestamp when this entry was created. */ + created_at: string; +} + +/** Schema for the global repos.json registry. */ +export interface RepoRegistry { + version: 1; + repos: RepoRegistryEntry[]; +} + +/** Metadata stored in each shared squad's manifest.json. */ +export interface SharedSquadManifest { + version: 1; + repoKey: string; + displayName?: string; + urlPatterns: string[]; + created_at: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const REPOS_JSON = 'repos.json'; +const REPOS_DIR = 'repos'; +const SQUAD_REPOS_POINTER = 'squad-repos.json'; + +// ============================================================================ +// Squad repo pointer resolution (~/.squad/squad-repos.json) +// ============================================================================ + +/** A registry entry paired with the squad repo root it came from. */ +export interface LocatedRegistryEntry { + entry: RepoRegistryEntry; + /** Root path of the squad repo (e.g. D:\git\akubly.squad). */ + squadRepoRoot: string; +} + +/** + * Load squad repo pointers from `~/.squad/squad-repos.json`. + * + * Returns an array of absolute paths to squad repo clones. + * Falls back to empty array if the file doesn't exist or is malformed. + */ +export function loadSquadRepoPointers(): string[] { + const squadDir = path.join(os.homedir(), '.squad'); + const pointerPath = path.join(squadDir, SQUAD_REPOS_POINTER); + + if (!storage.existsSync(pointerPath)) return []; + + try { + const raw = storage.readSync(pointerPath) ?? ''; + const parsed = JSON.parse(raw) as { squadRepos?: string[] }; + if (Array.isArray(parsed.squadRepos)) { + return parsed.squadRepos.filter( + (p): p is string => typeof p === 'string' && p.length > 0, + ); + } + } catch { + // Malformed pointer file — ignore + } + return []; +} + +/** + * Load a repos.json registry from a specific squad repo clone. + */ +function loadRegistryFrom(squadRepoRoot: string): RepoRegistry | null { + const registryPath = path.join(squadRepoRoot, REPOS_JSON); + if (!storage.existsSync(registryPath)) return null; + + try { + const raw = storage.readSync(registryPath) ?? ''; + const parsed: unknown = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === 'object' && + 'version' in parsed && + (parsed as Record).version === 1 && + 'repos' in parsed && + Array.isArray((parsed as Record).repos) + ) { + return parsed as RepoRegistry; + } + } catch { + // ignore + } + return null; +} + +/** + * Look up a normalized URL across all squad repo pointers, then fall back + * to the legacy %APPDATA% registry. + * + * Returns the matched entry AND the squad repo root it was found in, + * or null if no match. + */ +export function lookupByUrlAcrossRepos(normalizedUrl: string): LocatedRegistryEntry | null { + const lower = normalizedUrl.toLowerCase(); + + // 1. Check squad repo pointers (~/.squad/squad-repos.json) + const pointers = loadSquadRepoPointers(); + for (const repoRoot of pointers) { + const registry = loadRegistryFrom(repoRoot); + if (!registry) continue; + + for (const entry of registry.repos) { + for (const pattern of entry.urlPatterns) { + if (pattern.toLowerCase() === lower) { + return { entry, squadRepoRoot: repoRoot }; + } + } + } + } + + // 2. Fall back to legacy %APPDATA%/squad/repos.json + const legacyRegistry = loadRepoRegistry(); + if (legacyRegistry) { + for (const entry of legacyRegistry.repos) { + for (const pattern of entry.urlPatterns) { + if (pattern.toLowerCase() === lower) { + // Legacy: squad repo root is %APPDATA%/squad (team dirs under repos/) + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + } catch { + continue; + } + return { entry, squadRepoRoot: globalDir }; + } + } + } + } + + return null; +} + +/** + * Look up a repo key across all squad repo pointers, then fall back + * to the legacy %APPDATA% registry. + * + * Mirrors {@link lookupByUrlAcrossRepos} but matches by `entry.key` + * instead of URL patterns. + */ +export function lookupByKeyAcrossRepos(repoKey: string): LocatedRegistryEntry | null { + // 1. Check squad repo pointers (~/.squad/squad-repos.json) + const pointers = loadSquadRepoPointers(); + for (const repoRoot of pointers) { + const registry = loadRegistryFrom(repoRoot); + if (!registry) continue; + + const entry = registry.repos.find((r) => r.key === repoKey); + if (entry) { + return { entry, squadRepoRoot: repoRoot }; + } + } + + // 2. Fall back to legacy %APPDATA%/squad/repos.json + const legacyRegistry = loadRepoRegistry(); + if (legacyRegistry) { + const entry = legacyRegistry.repos.find((r) => r.key === repoKey); + if (entry) { + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + } catch { + return null; + } + return { entry, squadRepoRoot: globalDir }; + } + } + + return null; +} + +// ============================================================================ +// Registry I/O +// ============================================================================ + +/** + * Load the repo registry from `%APPDATA%/squad/repos.json`. + * + * @returns Parsed registry, or `null` if the file is missing or malformed. + */ +export function loadRepoRegistry(): RepoRegistry | null { + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + } catch { + // F11: %APPDATA% unreachable — registry not available + return null; + } + + const registryPath = path.join(globalDir, REPOS_JSON); + if (!storage.existsSync(registryPath)) { + return null; + } + + try { + const raw = storage.readSync(registryPath) ?? ''; + const parsed: unknown = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === 'object' && + 'version' in parsed && + (parsed as Record).version === 1 && + 'repos' in parsed && + Array.isArray((parsed as Record).repos) + ) { + return parsed as RepoRegistry; + } + return null; + } catch { + return null; + } +} + +/** + * Write the repo registry to `%APPDATA%/squad/repos.json`. + * + * @throws {Error} If `%APPDATA%` is unreachable or the write fails (F11). + */ +export function saveRepoRegistry(registry: RepoRegistry): void { + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + } catch (err) { + throw new Error( + `Cannot save repo registry: global config directory is unreachable. ` + + `Check that the global squad data directory is accessible. ` + + `Original error: ${(err as Error).message}` + ); + } + + const registryPath = path.join(globalDir, REPOS_JSON); + try { + storage.writeSync(registryPath, JSON.stringify(registry, null, 2) + '\n'); + } catch (err) { + throw new Error( + `Failed to write repo registry at "${registryPath}": ${(err as Error).message}` + ); + } +} + +// ============================================================================ +// CRUD Operations +// ============================================================================ + +/** + * Create a new shared squad directory and register it. + * + * 1. Validates the repo key. + * 2. Creates the repos root if needed, then validates the target path (F7/F1). + * 3. Creates the nested team directory and writes `manifest.json`. + * 4. Registers the entry in `repos.json`. + * + * @param repoKey - Canonical repo key (e.g. "microsoft/os/os.2020"). + * @param urlPatterns - Normalized URL patterns for clone matching. + * @returns Absolute path to the shared squad's team directory. + * @throws {Error} If the key is invalid, a squad already exists, or %APPDATA% is unreachable. + */ +export function createSharedSquad(repoKey: string, urlPatterns: string[]): string { + validateRepoKey(repoKey); + + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + } catch (err) { + throw new Error( + `Cannot create shared squad: global config directory is unreachable. ` + + `Check that the global squad data directory is accessible. ` + + `Original error: ${(err as Error).message}` + ); + } + + const reposRoot = path.join(globalDir, REPOS_DIR); + const teamDir = path.join(reposRoot, ...repoKey.split('/')); + + // Ensure repos root exists so validateWritePath can resolve against it + if (!storage.existsSync(reposRoot)) { + storage.mkdirSync(reposRoot, { recursive: true }); + } + + // Validate target path stays inside repos root BEFORE creating nested dirs + validateWritePath(teamDir, reposRoot); + + // Check for existing entry in registry + let registry = loadRepoRegistry(); + if (!registry) { + registry = { version: 1, repos: [] }; + } + if (registry.repos.some(r => r.key === repoKey)) { + throw new Error(`Shared squad for repo "${repoKey}" already exists.`); + } + + // Create team directory + storage.mkdirSync(teamDir, { recursive: true }); + + // Verify with realpathSync post-creation (catches symlink/junction redirects) + const realTeamDir = realpathSync(teamDir); + const realReposRoot = realpathSync(reposRoot); + if (!pathStartsWith(realTeamDir, realReposRoot + path.sep) && realTeamDir !== realReposRoot) { + throw new Error(`Path traversal detected: team directory escapes repos root.`); + } + + // Write manifest.json + const now = new Date().toISOString(); + const manifest: SharedSquadManifest = { + version: 1, + repoKey, + urlPatterns, + created_at: now, + }; + storage.writeSync( + path.join(teamDir, 'manifest.json'), + JSON.stringify(manifest, null, 2) + '\n' + ); + + // Register in repos.json + registry.repos.push({ key: repoKey, urlPatterns, created_at: now }); + saveRepoRegistry(registry); + + return teamDir; +} + +/** + * Create a shared squad inside a git-backed squad repo clone. + * + * Unlike `createSharedSquad` (which writes to platform app data), this + * writes team scaffolding to `{squadRepoRoot}/{key}/` and registers the + * entry in `{squadRepoRoot}/repos.json`. Also ensures the squad repo + * clone is listed in `~/.squad/squad-repos.json` for auto-discovery. + * + * @param squadRepoRoot - Absolute path to the squad repo clone (e.g. "D:\\git\\akubly.squad"). + * @param repoKey - Canonical repo key (e.g. "microsoft/os/os.2020"). + * @param urlPatterns - Normalized URL patterns for clone matching. + * @returns Absolute path to the shared squad's team directory. + */ +export function createSharedSquadInRepo( + squadRepoRoot: string, + repoKey: string, + urlPatterns: string[], +): string { + validateRepoKey(repoKey); + + const resolvedRoot = path.resolve(squadRepoRoot); + const teamDir = path.join(resolvedRoot, ...repoKey.split('/')); + + // Ensure squad repo root exists + if (!storage.existsSync(resolvedRoot)) { + storage.mkdirSync(resolvedRoot, { recursive: true }); + } + + // Validate target path stays inside squad repo root + validateWritePath(teamDir, resolvedRoot); + + // Check for existing entry in this repo's registry + let registry = loadRegistryFrom(resolvedRoot); + if (!registry) { + registry = { version: 1, repos: [] }; + } + if (registry.repos.some(r => r.key === repoKey)) { + throw new Error(`Shared squad for repo "${repoKey}" already exists in ${resolvedRoot}.`); + } + + // Create team directory + storage.mkdirSync(teamDir, { recursive: true }); + + // Verify with realpathSync post-creation + const realTeamDir = realpathSync(teamDir); + const realRoot = realpathSync(resolvedRoot); + if (!pathStartsWith(realTeamDir, realRoot + path.sep) && realTeamDir !== realRoot) { + throw new Error(`Path traversal detected: team directory escapes squad repo root.`); + } + + // Write manifest.json + const now = new Date().toISOString(); + const manifest: SharedSquadManifest = { + version: 1, + repoKey, + urlPatterns, + created_at: now, + }; + storage.writeSync( + path.join(teamDir, 'manifest.json'), + JSON.stringify(manifest, null, 2) + '\n', + ); + + // Register in this repo's repos.json + registry.repos.push({ key: repoKey, urlPatterns, created_at: now }); + const registryPath = path.join(resolvedRoot, REPOS_JSON); + storage.writeSync(registryPath, JSON.stringify(registry, null, 2) + '\n'); + + // Ensure this squad repo clone is in ~/.squad/squad-repos.json + addSquadRepoPointer(resolvedRoot); + + return teamDir; +} + +/** + * Add a squad repo clone path to `~/.squad/squad-repos.json`. + * Idempotent — skips if already listed. + * + * @param squadRepoRoot - Absolute path to the squad repo clone. + */ +export function addSquadRepoPointer(squadRepoRoot: string): void { + const resolvedRoot = path.resolve(squadRepoRoot); + const squadDir = path.join(os.homedir(), '.squad'); + const pointerPath = path.join(squadDir, SQUAD_REPOS_POINTER); + + // Load existing pointers + const existing = loadSquadRepoPointers(); + + // Check if already listed (case-insensitive on Windows/macOS) + const alreadyListed = existing.some(p => + CASE_INSENSITIVE + ? p.toLowerCase() === resolvedRoot.toLowerCase() + : p === resolvedRoot, + ); + if (alreadyListed) return; + + // Add and save + existing.push(resolvedRoot); + storage.mkdirSync(squadDir, { recursive: true }); + storage.writeSync( + pointerPath, + JSON.stringify({ squadRepos: existing }, null, 2) + '\n', + ); +} + +/** + * Look up a repo registry entry by normalized URL. + * + * Performs a case-insensitive comparison of the given URL against all + * registered URL patterns. + * + * @param normalizedUrl - A normalized URL to match (e.g. from `normalizeRemoteUrl().normalizedUrl`). + * @returns The matching entry, or `null` if no match is found. + */ +export function lookupByUrl(normalizedUrl: string): RepoRegistryEntry | null { + const registry = loadRepoRegistry(); + if (!registry) return null; + + const lower = normalizedUrl.toLowerCase(); + for (const entry of registry.repos) { + for (const pattern of entry.urlPatterns) { + if (pattern.toLowerCase() === lower) { + return entry; + } + } + } + return null; +} + +/** + * Full shared squad discovery: origin URL → registry lookup → resolved paths. + * + * Discovery constraint (F4): only matches the `origin` remote. + * If origin doesn't match any registered URL pattern, returns null. + * + * Note: This function constructs `ResolvedSquadPaths` directly. If `resolution.ts` + * needs to call this function in the future, extract `resolveGlobalSquadPath` and + * shared types into a cycle-free module to avoid circular imports. + * + * @param repoRoot - Absolute path to the git repository root. + * @returns Resolved paths with `mode: 'shared'`, or `null` if no match. + */ +export function resolveSharedSquad(repoRoot: string): ResolvedSquadPaths | null { + // Step 1: Get origin remote URL (F4: origin only) + const remoteUrl = getRemoteUrl(repoRoot); + if (!remoteUrl) return null; + + // Step 2: Normalize the URL + const normalized = normalizeRemoteUrl(remoteUrl); + + // Step 3: Look up across squad repo pointers + legacy %APPDATA% + const located = lookupByUrlAcrossRepos(normalized.normalizedUrl); + if (!located) return null; + + const { entry, squadRepoRoot } = located; + + // Step 4: Derive teamDir from squad repo root + key + // For git-backed repos: {squadRepoRoot}/{key} (files live directly in the clone) + // For legacy %APPDATA%: {squadRepoRoot}/repos/{key} + const isLegacyAppData = squadRepoRoot === tryResolveGlobalSquadPath(); + const teamDir = isLegacyAppData + ? path.join(squadRepoRoot, REPOS_DIR, ...entry.key.split('/')) + : path.join(squadRepoRoot, ...entry.key.split('/')); + + // Step 5: Validate teamDir exists + if (!storage.existsSync(teamDir)) return null; + + // Step 6: Validate with realpathSync — ensure teamDir is under the squad repo root + try { + const realTeamDir = realpathSync(teamDir); + const realRoot = realpathSync(squadRepoRoot); + if ( + !pathStartsWith(realTeamDir, realRoot + path.sep) && + realTeamDir !== realRoot + ) { + return null; + } + } catch { + return null; + } + + // Step 7: Resolve clone-local state dir for projectDir + const projectDir = resolveCloneStateDir(repoRoot, entry.key); + + return { + mode: 'shared', + projectDir, + teamDir, + personalDir: resolvePersonalSquadDir(), + config: null, + name: '.squad', + isLegacy: false, + }; +} + +/** Safe wrapper — returns null instead of throwing when global path is unreachable. */ +function tryResolveGlobalSquadPath(): string | null { + try { + return resolveGlobalSquadPath(); + } catch { + return null; + } +} + +/** + * Add a URL pattern to an existing registry entry (and its manifest). + * + * The pattern is normalized via `normalizeRemoteUrl()` before storing to + * ensure consistent matching. + * + * @param repoKey - The repo key whose entry to update. + * @param pattern - A URL (raw or normalized) to add as a matching pattern. + * @throws {Error} If the registry or entry doesn't exist. + */ +export function addUrlPattern(repoKey: string, pattern: string): void { + const registry = loadRepoRegistry(); + if (!registry) { + throw new Error('No repo registry found. Create a shared squad first.'); + } + + const entry = registry.repos.find(r => r.key === repoKey); + if (!entry) { + throw new Error(`Repo "${repoKey}" not found in registry.`); + } + + // Normalize the pattern for consistent matching + const normalizedPattern = normalizeRemoteUrl(pattern).normalizedUrl; + + if (!entry.urlPatterns.includes(normalizedPattern)) { + entry.urlPatterns.push(normalizedPattern); + saveRepoRegistry(registry); + + // Best-effort: update manifest.json too + try { + const globalDir = resolveGlobalSquadPath(); + const manifestPath = path.join(globalDir, REPOS_DIR, ...repoKey.split('/'), 'manifest.json'); + if (storage.existsSync(manifestPath)) { + const raw = storage.readSync(manifestPath) ?? ''; + const manifest = JSON.parse(raw) as SharedSquadManifest; + if (!manifest.urlPatterns.includes(normalizedPattern)) { + manifest.urlPatterns.push(normalizedPattern); + storage.writeSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + } + } + } catch { + // Manifest update is best-effort — registry is the source of truth for lookup + } + } +} diff --git a/packages/squad-sdk/src/storage/fs-storage-provider.ts b/packages/squad-sdk/src/storage/fs-storage-provider.ts index 765fa69fa..b8446ff0d 100644 --- a/packages/squad-sdk/src/storage/fs-storage-provider.ts +++ b/packages/squad-sdk/src/storage/fs-storage-provider.ts @@ -15,7 +15,9 @@ import { StorageError } from './storage-error.js'; * - Optional `rootDir` confines all operations to a specific directory tree. */ export class FSStorageProvider implements StorageProvider { - private static readonly CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin'; + private static readonly CASE_INSENSITIVE = + !process.env['SQUAD_CASE_SENSITIVE'] && + (process.platform === 'win32' || process.platform === 'darwin'); private readonly rootDir?: string; constructor(rootDir?: string) { diff --git a/packages/squad-sdk/src/tools/index.ts b/packages/squad-sdk/src/tools/index.ts index ef2067296..4ab696b9f 100644 --- a/packages/squad-sdk/src/tools/index.ts +++ b/packages/squad-sdk/src/tools/index.ts @@ -11,12 +11,14 @@ */ import * as path from 'node:path'; -import { randomUUID } from 'node:crypto'; +import { randomUUID, randomBytes } from 'node:crypto'; import type { SquadTool, SquadToolResult } from '../adapter/types.js'; import { trace, SpanStatusCode } from '../runtime/otel-api.js'; import type { StorageProvider } from '../storage/storage-provider.js'; import { FSStorageProvider } from '../storage/fs-storage-provider.js'; import type { SquadState } from '../state/squad-state.js'; +import type { ResolvedSquadPaths } from '../resolution-base.js'; +import { sanitizeJournalFilenameComponent, validateWritePath } from '../shared-squad.js'; const tracer = trace.getTracer('squad-sdk'); @@ -184,12 +186,22 @@ export class ToolRegistry { private sessionPoolGetter?: () => any; private storage: StorageProvider; private state?: SquadState; + private resolvedPaths: ResolvedSquadPaths; - constructor(squadRoot = '.squad', sessionPoolGetter?: () => any, storage: StorageProvider = new FSStorageProvider(), state?: SquadState) { + constructor(squadRoot = '.squad', sessionPoolGetter?: () => any, storage: StorageProvider = new FSStorageProvider(), state?: SquadState, resolvedPaths?: ResolvedSquadPaths) { this.squadRoot = squadRoot; this.sessionPoolGetter = sessionPoolGetter; this.storage = storage; this.state = state; + this.resolvedPaths = resolvedPaths ?? { + mode: 'local' as const, + projectDir: squadRoot, + teamDir: squadRoot, + personalDir: null, + config: null, + name: '.squad', + isLegacy: false, + }; this.registerSquadTools(); } @@ -280,16 +292,19 @@ export class ToolRegistry { return { textResultForLlm: 'Invalid author name: must contain only letters, numbers, hyphens, and underscores', resultType: 'failure', error: 'Invalid author' }; } try { - const inboxDir = path.join(this.squadRoot, 'decisions', 'inbox'); + const inboxDir = path.join(this.resolvedPaths.teamDir, 'decisions', 'inbox'); + + // Validate write target stays within the resolved shared squad root + // (shared squads may be rooted outside global app data, e.g. git-backed pointers) + if (this.resolvedPaths.mode === 'shared') { + validateWritePath(inboxDir, this.resolvedPaths.teamDir); + } const decisionId = randomUUID(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const slug = args.summary - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 50); - const filename = path.join(inboxDir, `${args.author}-${slug}.md`); + const sanitizedAuthor = sanitizeJournalFilenameComponent(args.author); + const hex = randomBytes(4).toString('hex'); + const filename = path.join(inboxDir, `${sanitizedAuthor}-${timestamp}-${hex}.md`); const content = [ `### ${timestamp}: ${args.summary}`, @@ -306,10 +321,11 @@ export class ToolRegistry { this.storage.writeSync(filename, content); + const basename = path.basename(filename); return { - textResultForLlm: `Decision written: ${args.author}-${slug}.md (ID: ${decisionId})`, + textResultForLlm: `Decision written: ${basename} (ID: ${decisionId})`, resultType: 'success', - toolTelemetry: { decisionId, filename, slug }, + toolTelemetry: { decisionId, filename: basename, slug: basename }, }; } catch (error) { return { @@ -375,8 +391,8 @@ export class ToolRegistry { }; } - // Fallback: raw StorageProvider - const historyFile = path.join(this.squadRoot, 'agents', args.agent, 'history.md'); + // Fallback: journal pattern — write to history/inbox/ instead of mutating history.md + const historyFile = path.join(this.resolvedPaths.teamDir, 'agents', args.agent, 'history.md'); if (!this.storage.existsSync(historyFile)) { return { @@ -386,32 +402,23 @@ export class ToolRegistry { }; } - const sectionHeader = `## ${SECTION_MAP[args.section] ?? 'Learnings'}`; + const sectionName = SECTION_MAP[args.section] ?? 'Learnings'; const timestamp = new Date().toISOString().slice(0, 10); - const entry = `\n### ${timestamp}\n${args.content}\n`; + const entry = `## ${sectionName}\n\n### ${timestamp}\n${args.content}\n`; - let content = this.storage.readSync(historyFile); - if (content === undefined) { - return { - textResultForLlm: `Agent history file not readable: agents/${args.agent}/history.md`, - resultType: 'failure', - error: 'History file could not be read', - }; - } - - // Find section and append - const sectionIndex = content.indexOf(sectionHeader); - if (sectionIndex !== -1) { - // Find next section or end of file - const nextSectionIndex = content.indexOf('\n## ', sectionIndex + sectionHeader.length); - const insertIndex = nextSectionIndex === -1 ? content.length : nextSectionIndex; - content = content.slice(0, insertIndex) + entry + content.slice(insertIndex); - } else { - // Section doesn't exist, append at end - content += `\n${sectionHeader}\n${entry}`; + const inboxDir = path.join(this.resolvedPaths.teamDir, 'agents', args.agent, 'history', 'inbox'); + + // Validate write target stays within the resolved shared squad root + // (shared squads may be rooted outside global app data, e.g. git-backed pointers) + if (this.resolvedPaths.mode === 'shared') { + validateWritePath(inboxDir, this.resolvedPaths.teamDir); } + const sanitizedAgent = sanitizeJournalFilenameComponent(args.agent); + const isoSafe = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const hex = randomBytes(4).toString('hex'); + const inboxFile = path.join(inboxDir, `${sanitizedAgent}-${isoSafe}-${hex}.md`); - this.storage.writeSync(historyFile, content); + this.storage.writeSync(inboxFile, entry); return { textResultForLlm: `Appended to ${args.agent} history (${args.section})`, diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index 01e18dfad..b8d4156c0 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -21,10 +21,18 @@ You are **Squad (Coordinator)** — the orchestrator for this project's AI team. - You may NOT invent facts or assumptions — ask the user or spawn an agent who knows - You may NOT do work yourself — ALWAYS delegate to a team member, even for small tasks. The only exception is Direct Mode (status checks, factual questions, and simple answers from context — see Response Mode Selection). -Check: Does `.squad/team.md` exist? (fall back to `.ai-team/team.md` for repos migrating from older installs) -- **No** → Init Mode -- **Yes, but `## Members` has zero roster entries** → Init Mode (treat as unconfigured — scaffold exists but no team was cast) -- **Yes, with roster entries** → Team Mode +**Resolve the team root** — find `.squad/team.md` using the FULL resolution chain (do NOT stop after local checks): + +1. **Local:** Check CWD and `git rev-parse --show-toplevel` for `.squad/team.md` (or `.ai-team/team.md` for legacy repos). +2. **Shared squad registry:** If no local `.squad/`, check `~/.squad/squad-repos.json` for git-backed squad repo pointers. For each clone path listed, read its `repos.json` and match the current repo's origin URL against `urlPatterns`. Also check `SQUAD_REPO_KEY` env var for direct key lookup. If matched, the team root is `{squad-repo-clone}/{key}/`. *(See Worktree Awareness for full details.)* +3. **Platform app data fallback:** Check the platform app data directory for `repos.json` with the same URL/key matching. +4. **Main-checkout fallback:** `git worktree list --porcelain` → check the main working tree for `.squad/`. + +**⚠️ You MUST attempt ALL 4 steps before concluding no squad exists.** + +- **Not found via any strategy** → Init Mode +- **Found but `## Members` has zero roster entries** → Init Mode (treat as unconfigured) +- **Found with roster entries** → Team Mode --- @@ -616,26 +624,40 @@ To enable full parallelism, shared writes use a drop-box pattern that eliminates Squad and all spawned agents may be running inside a **git worktree** rather than the main checkout. All `.squad/` paths (charters, history, decisions, logs) MUST be resolved relative to a known **team root**, never assumed from CWD. -**Two strategies for resolving the team root:** +**Three strategies for resolving the team root:** | Strategy | Team root | State scope | When to use | |----------|-----------|-------------|-------------| | **worktree-local** | Current worktree root | Branch-local — each worktree has its own `.squad/` state | Feature branches that need isolated decisions and history | +| **shared** | Git-backed squad repo (via `~/.squad/squad-repos.json` pointer) or platform app data | User-global — team identity shared across all clones of the same repo | Multiple clones of the same repo that share one squad, repos that can't commit `.squad/` | | **main-checkout** | Main working tree root | Shared — all worktrees read/write the main checkout's `.squad/` | Single source of truth for memories, decisions, and logs across all branches | +**Validation:** A `.squad/` directory must contain `team.md` or an `agents/` subdirectory to be recognized as a team root. This prevents false positives from the `~/.squad/` config directory. + **How the Coordinator resolves the team root (on every session start):** -1. **Check CWD first** — does `.squad/` exist in the current working directory? +1. **Check CWD first** — does `.squad/` exist (with `team.md` or `agents/`) in the current working directory? - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. -2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. -3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +2. Run `git rev-parse --show-toplevel` to get the current worktree root. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - - **No** → use **main-checkout** strategy. Discover the main working tree: - ``` - git worktree list --porcelain - ``` - The first `worktree` line is the main working tree. Team root = that path. -4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +3. No local `.squad/` → check **shared squad registry**: + a. If `SQUAD_REPO_KEY` env var is set, use it as the lookup key (skip URL matching). + b. Check `~/.squad/squad-repos.json` for git-backed repo pointers. + - For each squad repo clone path listed, read its `repos.json`. + - If using `SQUAD_REPO_KEY`: match by `entry.key`. + - If using URL: run `git remote get-url origin`, normalize, match against `urlPatterns`. + - Match found → Team root = `{squad-repo-clone}/{key}/` + c. Fall back to platform app data directory (e.g. `~/.local/share/squad/repos.json` on Linux, the standard app data directory on other platforms). + - Same key/URL matching as above. + - Match found → Team root = `{appdata}/squad/repos/{key}/` + d. No match → continue to step 4. +4. No shared match → use **main-checkout** strategy. Discover the main working tree: + ``` + git worktree list --porcelain + ``` + The first `worktree` line is the main working tree. Team root = that path. +5. Nothing found → **Init Mode**. No team root resolved — offer to initialize a new squad. +6. The user may override the strategy at any time (e.g., *"use main checkout for team state"*, *"keep team state in this worktree"*, or *"use shared squad for this repo"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. @@ -648,6 +670,13 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha - A `merge=union` driver in `.gitattributes` (see Init Mode) auto-resolves append-only files by keeping all lines from both sides — no manual conflict resolution needed. - The Scribe commits `.squad/` changes to the worktree's branch. State flows to other branches through normal git merge / PR workflow. +**Cross-worktree considerations (shared strategy):** +- Team root is outside the repo — in a git-backed squad repo clone or under platform app data. No repo writes needed. +- All clones of the same repo share one squad: same agents, charters, decisions, casting, and skills. +- Agent writes (history inbox, decisions inbox) go to the shared dir using the journal pattern (unique filenames, atomic creation, no contention across clones). +- Safe for concurrent sessions across clones. +- `TEAM_ROOT` passed to agents will be the external path. Agents don't need to know the mode. + **Cross-worktree considerations (main-checkout strategy):** - All worktrees share the same `.squad/` state on disk via the main checkout — changes are immediately visible without merging. - **Not safe for concurrent sessions.** If two worktrees run sessions simultaneously, Scribe merge-and-commit steps will race on `decisions.md` and git index. Use only when a single session is active at a time. diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index 01e18dfad..b8d4156c0 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -21,10 +21,18 @@ You are **Squad (Coordinator)** — the orchestrator for this project's AI team. - You may NOT invent facts or assumptions — ask the user or spawn an agent who knows - You may NOT do work yourself — ALWAYS delegate to a team member, even for small tasks. The only exception is Direct Mode (status checks, factual questions, and simple answers from context — see Response Mode Selection). -Check: Does `.squad/team.md` exist? (fall back to `.ai-team/team.md` for repos migrating from older installs) -- **No** → Init Mode -- **Yes, but `## Members` has zero roster entries** → Init Mode (treat as unconfigured — scaffold exists but no team was cast) -- **Yes, with roster entries** → Team Mode +**Resolve the team root** — find `.squad/team.md` using the FULL resolution chain (do NOT stop after local checks): + +1. **Local:** Check CWD and `git rev-parse --show-toplevel` for `.squad/team.md` (or `.ai-team/team.md` for legacy repos). +2. **Shared squad registry:** If no local `.squad/`, check `~/.squad/squad-repos.json` for git-backed squad repo pointers. For each clone path listed, read its `repos.json` and match the current repo's origin URL against `urlPatterns`. Also check `SQUAD_REPO_KEY` env var for direct key lookup. If matched, the team root is `{squad-repo-clone}/{key}/`. *(See Worktree Awareness for full details.)* +3. **Platform app data fallback:** Check the platform app data directory for `repos.json` with the same URL/key matching. +4. **Main-checkout fallback:** `git worktree list --porcelain` → check the main working tree for `.squad/`. + +**⚠️ You MUST attempt ALL 4 steps before concluding no squad exists.** + +- **Not found via any strategy** → Init Mode +- **Found but `## Members` has zero roster entries** → Init Mode (treat as unconfigured) +- **Found with roster entries** → Team Mode --- @@ -616,26 +624,40 @@ To enable full parallelism, shared writes use a drop-box pattern that eliminates Squad and all spawned agents may be running inside a **git worktree** rather than the main checkout. All `.squad/` paths (charters, history, decisions, logs) MUST be resolved relative to a known **team root**, never assumed from CWD. -**Two strategies for resolving the team root:** +**Three strategies for resolving the team root:** | Strategy | Team root | State scope | When to use | |----------|-----------|-------------|-------------| | **worktree-local** | Current worktree root | Branch-local — each worktree has its own `.squad/` state | Feature branches that need isolated decisions and history | +| **shared** | Git-backed squad repo (via `~/.squad/squad-repos.json` pointer) or platform app data | User-global — team identity shared across all clones of the same repo | Multiple clones of the same repo that share one squad, repos that can't commit `.squad/` | | **main-checkout** | Main working tree root | Shared — all worktrees read/write the main checkout's `.squad/` | Single source of truth for memories, decisions, and logs across all branches | +**Validation:** A `.squad/` directory must contain `team.md` or an `agents/` subdirectory to be recognized as a team root. This prevents false positives from the `~/.squad/` config directory. + **How the Coordinator resolves the team root (on every session start):** -1. **Check CWD first** — does `.squad/` exist in the current working directory? +1. **Check CWD first** — does `.squad/` exist (with `team.md` or `agents/`) in the current working directory? - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. -2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. -3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +2. Run `git rev-parse --show-toplevel` to get the current worktree root. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - - **No** → use **main-checkout** strategy. Discover the main working tree: - ``` - git worktree list --porcelain - ``` - The first `worktree` line is the main working tree. Team root = that path. -4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +3. No local `.squad/` → check **shared squad registry**: + a. If `SQUAD_REPO_KEY` env var is set, use it as the lookup key (skip URL matching). + b. Check `~/.squad/squad-repos.json` for git-backed repo pointers. + - For each squad repo clone path listed, read its `repos.json`. + - If using `SQUAD_REPO_KEY`: match by `entry.key`. + - If using URL: run `git remote get-url origin`, normalize, match against `urlPatterns`. + - Match found → Team root = `{squad-repo-clone}/{key}/` + c. Fall back to platform app data directory (e.g. `~/.local/share/squad/repos.json` on Linux, the standard app data directory on other platforms). + - Same key/URL matching as above. + - Match found → Team root = `{appdata}/squad/repos/{key}/` + d. No match → continue to step 4. +4. No shared match → use **main-checkout** strategy. Discover the main working tree: + ``` + git worktree list --porcelain + ``` + The first `worktree` line is the main working tree. Team root = that path. +5. Nothing found → **Init Mode**. No team root resolved — offer to initialize a new squad. +6. The user may override the strategy at any time (e.g., *"use main checkout for team state"*, *"keep team state in this worktree"*, or *"use shared squad for this repo"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. @@ -648,6 +670,13 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha - A `merge=union` driver in `.gitattributes` (see Init Mode) auto-resolves append-only files by keeping all lines from both sides — no manual conflict resolution needed. - The Scribe commits `.squad/` changes to the worktree's branch. State flows to other branches through normal git merge / PR workflow. +**Cross-worktree considerations (shared strategy):** +- Team root is outside the repo — in a git-backed squad repo clone or under platform app data. No repo writes needed. +- All clones of the same repo share one squad: same agents, charters, decisions, casting, and skills. +- Agent writes (history inbox, decisions inbox) go to the shared dir using the journal pattern (unique filenames, atomic creation, no contention across clones). +- Safe for concurrent sessions across clones. +- `TEAM_ROOT` passed to agents will be the external path. Agents don't need to know the mode. + **Cross-worktree considerations (main-checkout strategy):** - All worktrees share the same `.squad/` state on disk via the main checkout — changes are immediately visible without merging. - **Not safe for concurrent sessions.** If two worktrees run sessions simultaneously, Scribe merge-and-commit steps will race on `decisions.md` and git index. Use only when a single session is active at a time. diff --git a/test/cli-global.test.ts b/test/cli-global.test.ts index e9d640d6b..da91cfdb0 100644 --- a/test/cli-global.test.ts +++ b/test/cli-global.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdirSync, rmSync, existsSync } from 'node:fs'; +import { mkdirSync, rmSync, existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; import { resolveSquad, resolveGlobalSquadPath } from '@bradygaster/squad-sdk/resolution'; @@ -19,6 +19,11 @@ function scaffold(...dirs: string[]): void { for (const d of dirs) { mkdirSync(join(TMP, d), { recursive: true }); } + // .squad/ must contain team.md to be recognized as a team root + const squadDir = join(TMP, '.squad'); + if (existsSync(squadDir)) { + writeFileSync(join(squadDir, 'team.md'), '# Test Team\n'); + } } // ============================================================================ diff --git a/test/cli/shared.test.ts b/test/cli/shared.test.ts new file mode 100644 index 000000000..d2cd47692 --- /dev/null +++ b/test/cli/shared.test.ts @@ -0,0 +1,352 @@ +/** + * Tests for CLI shared squad commands: + * - squad init --shared + * - squad shared status|add-url|list|doctor + * - squad migrate --to shared + * + * Uses real temp directories to exercise file I/O. Overrides APPDATA + * (Windows) / XDG_CONFIG_HOME (Linux) to redirect global squad path + * into test dir. Mocks git remote via explicit --key argument. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; + +const TEST_ROOT = join(tmpdir(), `.test-cli-shared-${randomBytes(4).toString('hex')}`); +const FAKE_BASE = join(TEST_ROOT, 'appdata'); +const FAKE_GLOBAL = join(FAKE_BASE, 'squad'); + +/** Save and override the env var that resolveGlobalSquadPath reads. */ +let savedAppdata: string | undefined; +let savedXdg: string | undefined; + +function overrideGlobalDir(): void { + if (process.platform === 'win32') { + savedAppdata = process.env['APPDATA']; + process.env['APPDATA'] = FAKE_BASE; + } else { + savedXdg = process.env['XDG_CONFIG_HOME']; + process.env['XDG_CONFIG_HOME'] = FAKE_BASE; + } +} + +function restoreGlobalDir(): void { + if (process.platform === 'win32') { + if (savedAppdata !== undefined) process.env['APPDATA'] = savedAppdata; + else delete process.env['APPDATA']; + } else { + if (savedXdg !== undefined) process.env['XDG_CONFIG_HOME'] = savedXdg; + else delete process.env['XDG_CONFIG_HOME']; + } +} + +describe('CLI: init-shared command', () => { + beforeEach(() => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + mkdirSync(TEST_ROOT, { recursive: true }); + overrideGlobalDir(); + }); + + afterEach(() => { + restoreGlobalDir(); + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + }); + + it('exports runInitShared function', async () => { + const mod = await import('@bradygaster/squad-cli/commands/init-shared'); + expect(typeof mod.runInitShared).toBe('function'); + }); + + it('creates shared squad with explicit key', async () => { + const { runInitShared } = await import('@bradygaster/squad-cli/commands/init-shared'); + const cwd = join(TEST_ROOT, 'project'); + mkdirSync(cwd, { recursive: true }); + + // Mock console.log to capture output + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => { logs.push(args.join(' ')); }; + + try { + runInitShared(cwd, 'test-org/test-repo'); + } finally { + console.log = origLog; + } + + // Verify success output + expect(logs.some(l => l.includes('Created shared squad'))).toBe(true); + expect(logs.some(l => l.includes('test-org/test-repo'))).toBe(true); + expect(logs.some(l => l.includes('No files written to your repository'))).toBe(true); + + // Verify team dir was created with scaffolding + const teamDir = join(FAKE_GLOBAL, 'repos', 'test-org', 'test-repo'); + expect(existsSync(teamDir)).toBe(true); + expect(existsSync(join(teamDir, 'manifest.json'))).toBe(true); + expect(existsSync(join(teamDir, 'team.md'))).toBe(true); + expect(existsSync(join(teamDir, 'routing.md'))).toBe(true); + expect(existsSync(join(teamDir, 'decisions.md'))).toBe(true); + expect(existsSync(join(teamDir, 'agents'))).toBe(true); + expect(existsSync(join(teamDir, 'casting'))).toBe(true); + expect(existsSync(join(teamDir, 'decisions', 'inbox'))).toBe(true); + expect(existsSync(join(teamDir, 'skills'))).toBe(true); + + // Verify registry was created + const registry = JSON.parse(readFileSync(join(FAKE_GLOBAL, 'repos.json'), 'utf-8')); + expect(registry.version).toBe(1); + expect(registry.repos).toHaveLength(1); + expect(registry.repos[0].key).toBe('test-org/test-repo'); + + // Verify nothing was written to cwd + const cwdContents = existsSync(join(cwd, '.squad')); + expect(cwdContents).toBe(false); + }); + + it('attaches to existing squad instead of failing on duplicate key', async () => { + const { runInitShared } = await import('@bradygaster/squad-cli/commands/init-shared'); + const cwd = join(TEST_ROOT, 'project2'); + mkdirSync(cwd, { recursive: true }); + + // Create the squad first + runInitShared(cwd, 'test-org/dup-repo'); + + // Create it again — should not throw + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => { logs.push(args.join(' ')); }; + + try { + runInitShared(cwd, 'test-org/dup-repo'); + } finally { + console.log = origLog; + } + + expect(logs.some(l => l.includes('Connected to shared squad') || l.includes('already exists'))).toBe(true); + }); + + it('fails without key and without git remote', async () => { + const { runInitShared } = await import('@bradygaster/squad-cli/commands/init-shared'); + const cwd = join(TEST_ROOT, 'no-git'); + mkdirSync(cwd, { recursive: true }); + + expect(() => runInitShared(cwd)).toThrow(/Cannot auto-detect repo key/); + }); +}); + +describe('CLI: shared subcommands', () => { + beforeEach(() => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + mkdirSync(TEST_ROOT, { recursive: true }); + overrideGlobalDir(); + }); + + afterEach(() => { + restoreGlobalDir(); + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + }); + + it('exports runShared function', async () => { + const mod = await import('@bradygaster/squad-cli/commands/shared'); + expect(typeof mod.runShared).toBe('function'); + }); + + it('shared list shows empty registry', async () => { + const { runShared } = await import('@bradygaster/squad-cli/commands/shared'); + const cwd = join(TEST_ROOT, 'proj'); + mkdirSync(cwd, { recursive: true }); + + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => { logs.push(args.join(' ')); }; + + try { + runShared(cwd, 'list', []); + } finally { + console.log = origLog; + } + + expect(logs.some(l => l.includes('No shared squads registered'))).toBe(true); + }); + + it('shared list shows registered squads', async () => { + const { runShared } = await import('@bradygaster/squad-cli/commands/shared'); + const { runInitShared } = await import('@bradygaster/squad-cli/commands/init-shared'); + + const cwd = join(TEST_ROOT, 'proj-list'); + mkdirSync(cwd, { recursive: true }); + + // Register a squad + runInitShared(cwd, 'test-org/list-repo'); + + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => { logs.push(args.join(' ')); }; + + try { + runShared(cwd, 'list', []); + } finally { + console.log = origLog; + } + + expect(logs.some(l => l.includes('test-org/list-repo'))).toBe(true); + }); + + it('shared status shows not-in-shared hint when no shared squad', async () => { + const { runShared } = await import('@bradygaster/squad-cli/commands/shared'); + + const cwd = join(TEST_ROOT, 'proj-status'); + mkdirSync(cwd, { recursive: true }); + + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => { logs.push(args.join(' ')); }; + + try { + runShared(cwd, 'status', []); + } finally { + console.log = origLog; + } + + expect(logs.some(l => l.includes('Not in a shared squad'))).toBe(true); + }); + + it('shared doctor checks health', async () => { + const { runShared } = await import('@bradygaster/squad-cli/commands/shared'); + const { runInitShared } = await import('@bradygaster/squad-cli/commands/init-shared'); + + const cwd = join(TEST_ROOT, 'proj-doctor'); + mkdirSync(cwd, { recursive: true }); + + // Register a squad + runInitShared(cwd, 'test-org/doctor-repo'); + + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => { logs.push(args.join(' ')); }; + + try { + runShared(cwd, 'doctor', []); + } finally { + console.log = origLog; + } + + expect(logs.some(l => l.includes('Checking shared squad health'))).toBe(true); + expect(logs.some(l => l.includes('repos.json valid'))).toBe(true); + expect(logs.some(l => l.includes('team dir exists, manifest valid'))).toBe(true); + }); + + it('shared add-url with --key flag works without discovery', async () => { + const { runShared } = await import('@bradygaster/squad-cli/commands/shared'); + const { runInitShared } = await import('@bradygaster/squad-cli/commands/init-shared'); + + const cwd = join(TEST_ROOT, 'proj-addurl'); + mkdirSync(cwd, { recursive: true }); + + // Register a squad + runInitShared(cwd, 'test-org/addurl-repo'); + + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => { logs.push(args.join(' ')); }; + + try { + runShared(cwd, 'add-url', ['https://github.com/test-org/addurl-repo.git', '--key', 'test-org/addurl-repo']); + } finally { + console.log = origLog; + } + + expect(logs.some(l => l.includes('Added URL pattern'))).toBe(true); + + // Verify the pattern was added to registry + const registry = JSON.parse(readFileSync(join(FAKE_GLOBAL, 'repos.json'), 'utf-8')); + const entry = registry.repos.find((r: { key: string }) => r.key === 'test-org/addurl-repo'); + expect(entry.urlPatterns.length).toBeGreaterThanOrEqual(1); + }); + + it('shared add-url fails without pattern', async () => { + const { runShared } = await import('@bradygaster/squad-cli/commands/shared'); + const cwd = join(TEST_ROOT, 'proj-addurl-fail'); + mkdirSync(cwd, { recursive: true }); + + expect(() => runShared(cwd, 'add-url', [])).toThrow(/Usage/); + }); + + it('rejects unknown subcommand', async () => { + const { runShared } = await import('@bradygaster/squad-cli/commands/shared'); + const cwd = join(TEST_ROOT, 'proj-unknown'); + mkdirSync(cwd, { recursive: true }); + + expect(() => runShared(cwd, 'bogus', [])).toThrow(/Unknown shared subcommand/); + }); +}); + +describe('CLI: migrate --to shared', () => { + beforeEach(() => { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + mkdirSync(TEST_ROOT, { recursive: true }); + overrideGlobalDir(); + }); + + afterEach(() => { + restoreGlobalDir(); + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + }); + + it('migrates local .squad/ to shared mode with explicit key', async () => { + const { runMigrate } = await import('@bradygaster/squad-cli/commands/migrate'); + + const cwd = join(TEST_ROOT, 'proj-migrate'); + const squadDir = join(cwd, '.squad'); + mkdirSync(join(squadDir, 'agents', 'test-agent'), { recursive: true }); + mkdirSync(join(squadDir, 'decisions', 'inbox'), { recursive: true }); + writeFileSync(join(squadDir, 'team.md'), '# Test Team\n'); + writeFileSync(join(squadDir, 'routing.md'), '# Routing\n'); + writeFileSync(join(squadDir, 'decisions.md'), '# Decisions\n'); + writeFileSync(join(squadDir, 'agents', 'test-agent', 'charter.md'), '# Charter\n'); + + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => { logs.push(args.join(' ')); }; + + try { + await runMigrate(cwd, { to: 'shared', key: 'test-org/migrate-repo' }); + } finally { + console.log = origLog; + } + + // Verify success output + expect(logs.some(l => l.includes('Migrated to shared squad'))).toBe(true); + + // Verify files were copied to shared location + const teamDir = join(FAKE_GLOBAL, 'repos', 'test-org', 'migrate-repo'); + expect(existsSync(teamDir)).toBe(true); + expect(existsSync(join(teamDir, 'team.md'))).toBe(true); + expect(existsSync(join(teamDir, 'routing.md'))).toBe(true); + expect(existsSync(join(teamDir, 'decisions.md'))).toBe(true); + expect(existsSync(join(teamDir, 'agents', 'test-agent', 'charter.md'))).toBe(true); + + // Verify content was preserved + const content = readFileSync(join(teamDir, 'team.md'), 'utf-8'); + expect(content).toBe('# Test Team\n'); + + // Verify registry + const registry = JSON.parse(readFileSync(join(FAKE_GLOBAL, 'repos.json'), 'utf-8')); + expect(registry.repos).toHaveLength(1); + expect(registry.repos[0].key).toBe('test-org/migrate-repo'); + }); + + it('rejects migrate --to shared without .squad/ dir', async () => { + const { runMigrate } = await import('@bradygaster/squad-cli/commands/migrate'); + + const cwd = join(TEST_ROOT, 'proj-no-squad'); + mkdirSync(cwd, { recursive: true }); + + await expect( + runMigrate(cwd, { to: 'shared', key: 'test-org/no-squad' }), + ).rejects.toThrow(/No squad found/); + }); +}); diff --git a/test/clone-state.test.ts b/test/clone-state.test.ts new file mode 100644 index 000000000..7dd1cff0b --- /dev/null +++ b/test/clone-state.test.ts @@ -0,0 +1,309 @@ +/** + * Tests for clone-state.ts — clone-local runtime state resolution. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; + +const TMP = join(process.cwd(), `.test-clone-state-${randomBytes(4).toString('hex')}`); + +/** + * Helper: build a mock LOCALAPPDATA tree within TMP + */ +function makeFakeLocal(): string { + const localBase = join(TMP, 'local-appdata'); + mkdirSync(localBase, { recursive: true }); + return localBase; +} + +describe('clone-state', () => { + let fakeLocal: string; + + beforeEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + fakeLocal = makeFakeLocal(); + + // Stub LOCALAPPDATA / XDG_DATA_HOME so resolveLocalSquadBase() uses our temp dir + vi.stubEnv('LOCALAPPDATA', fakeLocal); + vi.stubEnv('XDG_DATA_HOME', fakeLocal); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + }); + + // Dynamically import to pick up env stubs + async function loadModule() { + // Force fresh import to pick up env changes + const mod = await import('@bradygaster/squad-sdk/clone-state'); + return mod; + } + + describe('resolveLocalSquadBase()', () => { + it('returns a path ending with squad', async () => { + const { resolveLocalSquadBase } = await loadModule(); + const result = resolveLocalSquadBase(); + expect(result).toMatch(/squad$/); + }); + + it('uses LOCALAPPDATA on Windows', async () => { + const { resolveLocalSquadBase } = await loadModule(); + if (process.platform === 'win32') { + expect(resolveLocalSquadBase()).toBe(join(fakeLocal, 'squad')); + } + }); + }); + + describe('resolveCloneStateDir()', () => { + it('derives path with correct structure', async () => { + const { resolveCloneStateDir } = await loadModule(); + const result = resolveCloneStateDir('/home/user/src/myrepo', 'bradygaster/squad'); + expect(result).toContain(join('repos', 'bradygaster', 'squad', 'clones', 'myrepo')); + }); + + it('lowercases the leaf name', async () => { + const { resolveCloneStateDir } = await loadModule(); + const result = resolveCloneStateDir('/home/user/src/MyRepo', 'bradygaster/squad'); + expect(result).toContain(join('clones', 'myrepo')); + }); + + it('handles 3-segment repo keys (ADO style)', async () => { + const { resolveCloneStateDir } = await loadModule(); + const result = resolveCloneStateDir('/home/user/src/os1', 'microsoft/os/os.2020'); + expect(result).toContain(join('repos', 'microsoft', 'os', 'os.2020', 'clones', 'os1')); + }); + + it('rejects invalid repo key with traversal segment', async () => { + const { resolveCloneStateDir } = await loadModule(); + expect(() => resolveCloneStateDir('/x/repo', '../bad/key')).toThrow(/traversal/); + }); + + it('rejects repo key with empty segment', async () => { + const { resolveCloneStateDir } = await loadModule(); + expect(() => resolveCloneStateDir('/x/repo', 'owner//repo')).toThrow(/empty/); + }); + + it('rejects repo key with single segment', async () => { + const { resolveCloneStateDir } = await loadModule(); + expect(() => resolveCloneStateDir('/x/repo', 'noslash')).toThrow(/2-3 segments/); + }); + + it('rejects repo key with uppercase', async () => { + const { resolveCloneStateDir } = await loadModule(); + expect(() => resolveCloneStateDir('/x/repo', 'Owner/Repo')).toThrow(/invalid characters/); + }); + + it('returns base slot when no collision exists', async () => { + const { resolveCloneStateDir } = await loadModule(); + const dir = resolveCloneStateDir('/a/repo1', 'owner/repo'); + expect(dir).toMatch(/clones[/\\]repo1$/); + }); + + it('prepends parent dir for generic leaf name "src"', async () => { + const { resolveCloneStateDir } = await loadModule(); + const dir = resolveCloneStateDir('/git/os/clone1/src', 'microsoft/os'); + expect(dir).toMatch(/clones[/\\]clone1-src$/); + }); + + it('prepends parent dir for generic leaf name "main"', async () => { + const { resolveCloneStateDir } = await loadModule(); + const dir = resolveCloneStateDir('/git/project/main', 'owner/repo'); + expect(dir).toMatch(/clones[/\\]project-main$/); + }); + + it('two generic-leaf clones resolve to distinct dirs', async () => { + const { resolveCloneStateDir, ensureCloneState } = await loadModule(); + // Register first clone + ensureCloneState('/git/os/clone1/src', 'microsoft/os'); + // Resolve second clone with different parent + const dir2 = resolveCloneStateDir('/git/os/clone2/src', 'microsoft/os'); + expect(dir2).toMatch(/clone2-src/); + expect(dir2).not.toMatch(/clone1-src/); + }); + + it('does not prepend parent for non-generic leaf', async () => { + const { resolveCloneStateDir } = await loadModule(); + const dir = resolveCloneStateDir('/git/os/myproject', 'owner/repo'); + expect(dir).toMatch(/clones[/\\]myproject$/); + }); + + it('detects collision and appends suffix', async () => { + const { resolveCloneStateDir, resolveLocalSquadBase } = await loadModule(); + // Pre-create the base slot with a different clone + const base = resolveLocalSquadBase(); + const clonesDir = join(base, 'repos', 'owner', 'repo', 'clones', 'sameleaf'); + mkdirSync(clonesDir, { recursive: true }); + writeFileSync(join(clonesDir, 'clone.json'), JSON.stringify({ + clonePath: '/other/path/sameleaf', + repoKey: 'owner/repo', + firstSeen: '2025-01-01T00:00:00Z', + lastSeen: '2025-01-01T00:00:00Z', + })); + + const result = resolveCloneStateDir('/my/path/sameleaf', 'owner/repo'); + expect(result).toMatch(/sameleaf-2$/); + }); + + it('finds already-registered clone in suffixed slot', async () => { + const { resolveCloneStateDir, resolveLocalSquadBase } = await loadModule(); + const base = resolveLocalSquadBase(); + const clonesDir = join(base, 'repos', 'owner', 'repo', 'clones'); + + // Base slot: different clone + const baseDir = join(clonesDir, 'leaf'); + mkdirSync(baseDir, { recursive: true }); + writeFileSync(join(baseDir, 'clone.json'), JSON.stringify({ + clonePath: '/other/leaf', + repoKey: 'owner/repo', + firstSeen: '2025-01-01T00:00:00Z', + lastSeen: '2025-01-01T00:00:00Z', + })); + + // Slot -2: our clone (already registered) + const slot2 = join(clonesDir, 'leaf-2'); + mkdirSync(slot2, { recursive: true }); + writeFileSync(join(slot2, 'clone.json'), JSON.stringify({ + clonePath: '/my/leaf', + repoKey: 'owner/repo', + firstSeen: '2025-01-01T00:00:00Z', + lastSeen: '2025-01-01T00:00:00Z', + })); + + const result = resolveCloneStateDir('/my/leaf', 'owner/repo'); + expect(result).toBe(slot2); + }); + + it('handles suffix gap (leaf-3 exists but leaf-2 is free)', async () => { + const { resolveCloneStateDir, resolveLocalSquadBase } = await loadModule(); + const base = resolveLocalSquadBase(); + const clonesDir = join(base, 'repos', 'owner', 'repo', 'clones'); + + // Base slot: different clone + const baseDir = join(clonesDir, 'leaf'); + mkdirSync(baseDir, { recursive: true }); + writeFileSync(join(baseDir, 'clone.json'), JSON.stringify({ + clonePath: '/x/leaf', + repoKey: 'owner/repo', + firstSeen: '2025-01-01T00:00:00Z', + lastSeen: '2025-01-01T00:00:00Z', + })); + + // No slot -2 — it's free + // Slot -3: exists but belongs to another clone + // Note: resolveCloneStateDir won't scan past missing slots, so -2 is returned + const result = resolveCloneStateDir('/new/leaf', 'owner/repo'); + expect(result).toMatch(/leaf-2$/); + }); + + it('claims dir with malformed clone.json', async () => { + const { resolveCloneStateDir, resolveLocalSquadBase } = await loadModule(); + const base = resolveLocalSquadBase(); + const dir = join(base, 'repos', 'owner', 'repo', 'clones', 'myapp'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'clone.json'), 'not json'); + + const result = resolveCloneStateDir('/any/myapp', 'owner/repo'); + // Should claim the dir since clone.json is malformed + expect(result).toBe(dir); + }); + + it('is idempotent — same clonePath returns same dir', async () => { + const { resolveCloneStateDir } = await loadModule(); + const r1 = resolveCloneStateDir('/home/user/repo', 'owner/repo'); + const r2 = resolveCloneStateDir('/home/user/repo', 'owner/repo'); + expect(r1).toBe(r2); + }); + }); + + describe('ensureCloneState()', () => { + it('creates directory and writes clone.json', async () => { + const { ensureCloneState } = await loadModule(); + const dir = ensureCloneState('/home/user/myrepo', 'owner/repo'); + + expect(existsSync(dir)).toBe(true); + const jsonPath = join(dir, 'clone.json'); + expect(existsSync(jsonPath)).toBe(true); + + const meta = JSON.parse(readFileSync(jsonPath, 'utf-8')); + expect(meta.repoKey).toBe('owner/repo'); + expect(meta.firstSeen).toBeTruthy(); + expect(meta.lastSeen).toBeTruthy(); + }); + + it('clone.json contains normalized clonePath', async () => { + const { ensureCloneState } = await loadModule(); + const dir = ensureCloneState('/home/user/myrepo/', 'owner/repo'); + const meta = JSON.parse(readFileSync(join(dir, 'clone.json'), 'utf-8')); + // Should not have trailing separator + expect(meta.clonePath).not.toMatch(/[/\\]$/); + }); + + it('updates lastSeen on second call without changing firstSeen', async () => { + const { ensureCloneState } = await loadModule(); + const dir = ensureCloneState('/home/user/repo', 'owner/repo'); + const meta1 = JSON.parse(readFileSync(join(dir, 'clone.json'), 'utf-8')); + + // Small delay to ensure timestamps differ + const beforeSecondCall = Date.now(); + // Modify firstSeen slightly to verify it's preserved + const origFirstSeen = meta1.firstSeen; + + const dir2 = ensureCloneState('/home/user/repo', 'owner/repo'); + expect(dir2).toBe(dir); + + const meta2 = JSON.parse(readFileSync(join(dir, 'clone.json'), 'utf-8')); + expect(meta2.firstSeen).toBe(origFirstSeen); + // lastSeen should be updated (or at least not earlier) + expect(new Date(meta2.lastSeen).getTime()).toBeGreaterThanOrEqual( + new Date(meta1.lastSeen).getTime() + ); + }); + + it('clone.json has expected schema', async () => { + const { ensureCloneState } = await loadModule(); + const dir = ensureCloneState('/home/user/myrepo', 'owner/repo'); + const meta = JSON.parse(readFileSync(join(dir, 'clone.json'), 'utf-8')); + + expect(meta).toHaveProperty('clonePath'); + expect(meta).toHaveProperty('repoKey'); + expect(meta).toHaveProperty('firstSeen'); + expect(meta).toHaveProperty('lastSeen'); + expect(typeof meta.clonePath).toBe('string'); + expect(typeof meta.repoKey).toBe('string'); + // ISO 8601 format check + expect(() => new Date(meta.firstSeen)).not.toThrow(); + expect(() => new Date(meta.lastSeen)).not.toThrow(); + }); + + it('handles collision in ensureCloneState', async () => { + const { ensureCloneState, resolveLocalSquadBase } = await loadModule(); + + // Pre-register a different clone with same leaf + const base = resolveLocalSquadBase(); + const existingDir = join(base, 'repos', 'owner', 'repo', 'clones', 'samename'); + mkdirSync(existingDir, { recursive: true }); + writeFileSync(join(existingDir, 'clone.json'), JSON.stringify({ + clonePath: '/different/samename', + repoKey: 'owner/repo', + firstSeen: '2025-01-01T00:00:00Z', + lastSeen: '2025-01-01T00:00:00Z', + })); + + // This should get a suffixed directory + const dir = ensureCloneState('/my/path/samename', 'owner/repo'); + expect(dir).toMatch(/samename-2/); + expect(existsSync(join(dir, 'clone.json'))).toBe(true); + }); + + it('returns the same dir for same clone across calls', async () => { + const { ensureCloneState } = await loadModule(); + const d1 = ensureCloneState('/home/user/repo', 'owner/repo'); + const d2 = ensureCloneState('/home/user/repo', 'owner/repo'); + expect(d1).toBe(d2); + }); + }); +}); diff --git a/test/dual-root-resolver.test.ts b/test/dual-root-resolver.test.ts index 63a6b8c1a..f9cce04fa 100644 --- a/test/dual-root-resolver.test.ts +++ b/test/dual-root-resolver.test.ts @@ -14,6 +14,10 @@ const TMP = join(process.cwd(), `.test-dual-root-${randomBytes(4).toString('hex' function scaffold(...dirs: string[]): void { for (const d of dirs) { mkdirSync(join(TMP, d), { recursive: true }); + // resolveSquad() requires team.md, agents/, or config.json to recognize a squad dir + if (d === '.squad' || d === '.ai-team') { + writeFileSync(join(TMP, d, 'team.md'), '# Team\n'); + } } } diff --git a/test/integration.test.ts b/test/integration.test.ts index fd6b645ed..87a145557 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -234,9 +234,13 @@ describe('Integration: Tool → Hook Pipeline', () => { const scrubbedResult = await pipeline.runPostToolHooks(postCtx); - // Check file content was written (PII in file system is OK for squad_memory) - const historyContent = fs.readFileSync(path.join(agentDir, 'history.md'), 'utf-8'); - expect(historyContent).toContain('john.doe@example.com'); + // Check file content was written to inbox (journal pattern — no direct history.md mutation) + const inboxDir = path.join(agentDir, 'history', 'inbox'); + expect(fs.existsSync(inboxDir)).toBe(true); + const inboxFiles = fs.readdirSync(inboxDir); + expect(inboxFiles.length).toBe(1); + const inboxContent = fs.readFileSync(path.join(inboxDir, inboxFiles[0]), 'utf-8'); + expect(inboxContent).toContain('john.doe@example.com'); // But if we return the result to LLM, it should be scrubbed const resultText = JSON.stringify(scrubbedResult.result); diff --git a/test/multi-squad.test.ts b/test/multi-squad.test.ts index 178df3537..e4aa54a9f 100644 --- a/test/multi-squad.test.ts +++ b/test/multi-squad.test.ts @@ -115,7 +115,7 @@ describe('getSquadRoot()', () => { it('returns a platform-appropriate path containing "squad"', () => { // The function should return something like: // Linux/macOS: ~/.config/squad (or $XDG_CONFIG_HOME/squad) - // Windows: %APPDATA%/squad (or %LOCALAPPDATA%/squad) + // Windows: /squad (roaming or local app data dir) const expectedSegments = platform() === 'win32' ? ['squad'] : ['.config', 'squad']; diff --git a/test/personal-squad-init.test.ts b/test/personal-squad-init.test.ts index 3a2c4ad18..c145d3998 100644 --- a/test/personal-squad-init.test.ts +++ b/test/personal-squad-init.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdir, rm, writeFile, readFile } from 'fs/promises'; import { join, sep } from 'path'; -import { existsSync, mkdirSync, rmSync } from 'fs'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { randomBytes } from 'crypto'; import { resolveGlobalSquadPath, @@ -220,6 +220,8 @@ describe('resolveSquadPaths — includes personalDir', () => { beforeEach(() => { cleanup(); mkdirSync(squadDir, { recursive: true }); + // .squad/ must contain team.md to be recognized as a team root + writeFileSync(join(squadDir, 'team.md'), '# Test Team\n'); }); afterEach(() => { cleanup(); vi.unstubAllEnvs(); }); diff --git a/test/platform-adapter.test.ts b/test/platform-adapter.test.ts index c14748973..e47a119b2 100644 --- a/test/platform-adapter.test.ts +++ b/test/platform-adapter.test.ts @@ -7,7 +7,9 @@ import { detectPlatformFromUrl, parseGitHubRemote, parseAzureDevOpsRemote, + normalizeRemoteUrl, } from '../packages/squad-sdk/src/platform/detect.js'; +import type { NormalizedRemote } from '../packages/squad-sdk/src/platform/detect.js'; import { detectWorkItemSource } from '../packages/squad-sdk/src/platform/detect.js'; import { getRalphScanCommands } from '../packages/squad-sdk/src/platform/ralph-commands.js'; import { mapPlannerTaskToWorkItem } from '../packages/squad-sdk/src/platform/planner.js'; @@ -1036,3 +1038,229 @@ describe('ADO exports from platform index', () => { expect(types.length).toBeGreaterThan(0); }); }); + +// ─── normalizeRemoteUrl ─────────────────────────────────────────────── + +describe('normalizeRemoteUrl', () => { + // ── GitHub ──────────────────────────────────────────────────────────── + + it('normalizes GitHub HTTPS URL', () => { + const r = normalizeRemoteUrl('https://github.com/owner/repo.git'); + expect(r.provider).toBe('github'); + expect(r.org).toBe('owner'); + expect(r.repo).toBe('repo'); + expect(r.key).toBe('owner/repo'); + expect(r.normalizedUrl).toBe('github.com/owner/repo'); + expect(r.project).toBeUndefined(); + }); + + it('normalizes GitHub HTTPS URL without .git', () => { + const r = normalizeRemoteUrl('https://github.com/bradygaster/squad'); + expect(r.key).toBe('bradygaster/squad'); + expect(r.normalizedUrl).toBe('github.com/bradygaster/squad'); + }); + + it('normalizes GitHub SSH URL', () => { + const r = normalizeRemoteUrl('git@github.com:owner/repo.git'); + expect(r.provider).toBe('github'); + expect(r.key).toBe('owner/repo'); + expect(r.normalizedUrl).toBe('github.com/owner/repo'); + }); + + it('normalizes GitHub SSH URL without .git', () => { + const r = normalizeRemoteUrl('git@github.com:bradygaster/squad'); + expect(r.key).toBe('bradygaster/squad'); + expect(r.normalizedUrl).toBe('github.com/bradygaster/squad'); + }); + + // ── GitHub — ssh:// form ────────────────────────────────────────────── + + it('normalizes GitHub ssh:// URL', () => { + const r = normalizeRemoteUrl('ssh://git@github.com/owner/repo.git'); + expect(r.provider).toBe('github'); + expect(r.org).toBe('owner'); + expect(r.repo).toBe('repo'); + expect(r.key).toBe('owner/repo'); + expect(r.normalizedUrl).toBe('github.com/owner/repo'); + }); + + it('normalizes GitHub ssh:// URL without .git', () => { + const r = normalizeRemoteUrl('ssh://git@github.com/bradygaster/squad'); + expect(r.key).toBe('bradygaster/squad'); + expect(r.normalizedUrl).toBe('github.com/bradygaster/squad'); + }); + + it('normalizes GitHub ssh:// URL without user@', () => { + const r = normalizeRemoteUrl('ssh://github.com/owner/repo.git'); + expect(r.provider).toBe('github'); + expect(r.key).toBe('owner/repo'); + }); + + it('produces same key for GitHub HTTPS, SSH, and ssh:// forms', () => { + const https = normalizeRemoteUrl('https://github.com/bradygaster/squad'); + const ssh = normalizeRemoteUrl('git@github.com:bradygaster/squad.git'); + const sshUrl = normalizeRemoteUrl('ssh://git@github.com/bradygaster/squad.git'); + expect(https.key).toBe(ssh.key); + expect(https.key).toBe(sshUrl.key); + }); + + // ── Azure DevOps — modern HTTPS ────────────────────────────────────── + + it('normalizes ADO HTTPS modern URL', () => { + const r = normalizeRemoteUrl('https://dev.azure.com/microsoft/OS/_git/os.2020'); + expect(r.provider).toBe('azure-devops'); + expect(r.org).toBe('microsoft'); + expect(r.project).toBe('os'); + expect(r.repo).toBe('os.2020'); + expect(r.key).toBe('microsoft/os/os.2020'); + expect(r.normalizedUrl).toBe('dev.azure.com/microsoft/os/_git/os.2020'); + }); + + it('normalizes ADO HTTPS with user prefix', () => { + const r = normalizeRemoteUrl('https://myorg@dev.azure.com/myorg/MyProject/_git/my-repo'); + expect(r.key).toBe('myorg/myproject/my-repo'); + expect(r.org).toBe('myorg'); + expect(r.project).toBe('myproject'); + }); + + // ── Azure DevOps — SSH ─────────────────────────────────────────────── + + it('normalizes ADO SSH URL', () => { + const r = normalizeRemoteUrl('git@ssh.dev.azure.com:v3/microsoft/OS/os.2020'); + expect(r.provider).toBe('azure-devops'); + expect(r.key).toBe('microsoft/os/os.2020'); + expect(r.normalizedUrl).toBe('ssh.dev.azure.com/microsoft/os/os.2020'); + }); + + // ── Azure DevOps — legacy visualstudio.com ─────────────────────────── + + it('normalizes ADO legacy URL with DefaultCollection', () => { + const r = normalizeRemoteUrl( + 'https://microsoft.visualstudio.com/DefaultCollection/OS/_git/os.2020', + ); + expect(r.provider).toBe('azure-devops'); + expect(r.key).toBe('microsoft/os/os.2020'); + expect(r.normalizedUrl).toBe('microsoft.visualstudio.com/os/_git/os.2020'); + }); + + it('normalizes ADO legacy URL without DefaultCollection', () => { + const r = normalizeRemoteUrl( + 'https://microsoft.visualstudio.com/OS/_git/os.2020', + ); + expect(r.key).toBe('microsoft/os/os.2020'); + expect(r.normalizedUrl).toBe('microsoft.visualstudio.com/os/_git/os.2020'); + }); + + // ── Azure DevOps — ssh:// form ──────────────────────────────────────── + + it('normalizes ADO ssh:// URL', () => { + const r = normalizeRemoteUrl('ssh://git@ssh.dev.azure.com/v3/microsoft/OS/os.2020'); + expect(r.provider).toBe('azure-devops'); + expect(r.org).toBe('microsoft'); + expect(r.project).toBe('os'); + expect(r.repo).toBe('os.2020'); + expect(r.key).toBe('microsoft/os/os.2020'); + expect(r.normalizedUrl).toBe('ssh.dev.azure.com/microsoft/os/os.2020'); + }); + + it('normalizes ADO ssh:// URL with .git suffix', () => { + const r = normalizeRemoteUrl('ssh://git@ssh.dev.azure.com/v3/myorg/MyProject/my-repo.git'); + expect(r.provider).toBe('azure-devops'); + expect(r.key).toBe('myorg/myproject/my-repo'); + }); + + it('normalizes ADO ssh:// URL without user@', () => { + const r = normalizeRemoteUrl('ssh://ssh.dev.azure.com/v3/microsoft/OS/os.2020'); + expect(r.provider).toBe('azure-devops'); + expect(r.key).toBe('microsoft/os/os.2020'); + }); + + // ── Cross-format key equivalence ───────────────────────────────────── + + it('produces same key for all ADO URL formats including ssh://', () => { + const urls = [ + 'https://microsoft.visualstudio.com/DefaultCollection/OS/_git/os.2020', + 'https://microsoft.visualstudio.com/OS/_git/os.2020', + 'https://dev.azure.com/microsoft/OS/_git/os.2020', + 'git@ssh.dev.azure.com:v3/microsoft/OS/os.2020', + 'ssh://git@ssh.dev.azure.com/v3/microsoft/OS/os.2020', + ]; + const keys = urls.map((u) => normalizeRemoteUrl(u).key); + const unique = new Set(keys); + expect(unique.size).toBe(1); + expect(keys[0]).toBe('microsoft/os/os.2020'); + }); + + // ── Lowercasing ────────────────────────────────────────────────────── + + it('always lowercases the key', () => { + expect(normalizeRemoteUrl('https://github.com/Owner/Repo.git').key).toBe('owner/repo'); + expect(normalizeRemoteUrl('https://dev.azure.com/ORG/PROJECT/_git/REPO').key).toBe( + 'org/project/repo', + ); + expect( + normalizeRemoteUrl('https://ORG.visualstudio.com/PROJECT/_git/REPO').key, + ).toBe('org/project/repo'); + }); + + // ── Edge cases ─────────────────────────────────────────────────────── + + it('handles dotted repo names (os.2020)', () => { + const r = normalizeRemoteUrl('https://github.com/owner/some.dotted.repo.git'); + expect(r.repo).toBe('some.dotted.repo'); + expect(r.key).toBe('owner/some.dotted.repo'); + }); + + it('handles empty string as unknown', () => { + const r = normalizeRemoteUrl(''); + expect(r.provider).toBe('unknown'); + expect(r.key).toBe(''); + }); + + it('handles whitespace-only input as unknown', () => { + const r = normalizeRemoteUrl(' '); + expect(r.provider).toBe('unknown'); + }); + + it('handles unknown provider URL', () => { + const r = normalizeRemoteUrl('https://gitlab.com/group/subgroup/repo.git'); + expect(r.provider).toBe('unknown'); + expect(r.repo).toBe('repo'); + expect(r.org).toBe(''); + expect(r.key).toBe('gitlab.com/group/subgroup/repo'); + }); + + it('returns unknown for GitHub URL with extra path segments', () => { + const r = normalizeRemoteUrl('https://github.com/owner/repo/issues'); + expect(r.provider).toBe('unknown'); + }); + + it('returns unknown for ADO URL with extra path segments', () => { + const r = normalizeRemoteUrl('https://dev.azure.com/org/proj/_git/repo/extra'); + expect(r.provider).toBe('unknown'); + }); + + it('strips .git from ADO SSH repo name', () => { + const r = normalizeRemoteUrl('git@ssh.dev.azure.com:v3/org/proj/repo.git'); + expect(r.repo).toBe('repo'); + expect(r.key).toBe('org/proj/repo'); + }); + + it('strips .git from ADO legacy repo name', () => { + const r = normalizeRemoteUrl('https://org.visualstudio.com/proj/_git/repo.git'); + expect(r.repo).toBe('repo'); + }); + + it('strips trailing slash from GitHub HTTPS', () => { + const r = normalizeRemoteUrl('https://github.com/owner/repo/'); + expect(r.provider).toBe('github'); + expect(r.key).toBe('owner/repo'); + }); + + // ── Barrel re-export verification ──────────────────────────────────── + + it('is re-exported from platform/index.ts', async () => { + const mod = await import('../packages/squad-sdk/src/platform/index.js'); + expect(typeof mod.normalizeRemoteUrl).toBe('function'); + }); +}); diff --git a/test/resolution-shared-mode.test.ts b/test/resolution-shared-mode.test.ts new file mode 100644 index 000000000..334bac49e --- /dev/null +++ b/test/resolution-shared-mode.test.ts @@ -0,0 +1,387 @@ +/** + * Tests for resolveSquadPaths() — shared mode resolution (Issue #311). + * + * Tests the step-3 shared squad discovery that runs when no local + * .squad/ directory is found. Covers: + * - SQUAD_REPO_KEY direct key lookup + * - URL-based discovery via origin remote + * - SQUAD_APPDATA_OVERRIDE environment variable + * - %APPDATA% unreachable → SquadError + * - Backward compatibility (local/remote modes unchanged) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, rmSync, existsSync, writeFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { resolveSquadPaths, _resetAppdataOverrideWarned } from '@bradygaster/squad-sdk/resolution'; +import { SquadError } from '@bradygaster/squad-sdk/adapter/errors'; + +const TMP = join(process.cwd(), `.test-shared-mode-${randomBytes(4).toString('hex')}`); + +function scaffold(...dirs: string[]): void { + for (const d of dirs) { + mkdirSync(join(TMP, d), { recursive: true }); + } +} + +function writeJson(relPath: string, data: unknown): void { + writeFileSync(join(TMP, relPath), JSON.stringify(data, null, 2), 'utf-8'); +} + +/** Create a bare git repo at the given path with an origin remote. */ +function initGitRepoWithOrigin(repoDir: string, originUrl: string): void { + mkdirSync(repoDir, { recursive: true }); + execSync('git init', { cwd: repoDir, stdio: 'pipe' }); + execSync(`git remote add origin ${originUrl}`, { cwd: repoDir, stdio: 'pipe' }); +} + +/** Write a repos.json registry file at the given appdata/squad/ directory. */ +function writeRegistry( + appdataDir: string, + repos: Array<{ key: string; urlPatterns: string[] }>, +): void { + const globalSquadDir = join(appdataDir, 'squad'); + mkdirSync(globalSquadDir, { recursive: true }); + const registry = { + version: 1, + repos: repos.map((r) => ({ + key: r.key, + urlPatterns: r.urlPatterns, + created_at: '2025-07-22T10:00:00Z', + })), + }; + writeFileSync(join(globalSquadDir, 'repos.json'), JSON.stringify(registry, null, 2), 'utf-8'); +} + +/** Create the team directory under appdata/squad/repos/{key}. */ +function createTeamDir(appdataDir: string, repoKey: string): string { + const teamDir = join(appdataDir, 'squad', 'repos', ...repoKey.split('/')); + mkdirSync(teamDir, { recursive: true }); + writeJson( + join(teamDir, 'manifest.json').replace(TMP + (process.platform === 'win32' ? '\\' : '/'), ''), + { version: 1, repoKey, urlPatterns: [], created_at: '2025-07-22T10:00:00Z' }, + ); + return teamDir; +} + +describe('resolveSquadPaths() — shared mode', () => { + const appdataDir = join(TMP, 'appdata'); + const repoDir = join(TMP, 'repo'); + + beforeEach(() => { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + }); + + // ──── Backward compatibility ──── + + it('local mode still works (no regression)', () => { + scaffold('.git', '.squad', '.squad/agents'); + const result = resolveSquadPaths(TMP); + expect(result).not.toBeNull(); + expect(result!.mode).toBe('local'); + expect(result!.projectDir).toBe(join(TMP, '.squad')); + expect(result!.teamDir).toBe(join(TMP, '.squad')); + }); + + it('remote mode still works (no regression)', () => { + scaffold('.git', '.squad', 'shared-team'); + writeJson('.squad/config.json', { + version: 1, + teamRoot: 'shared-team', + projectKey: null, + }); + + const result = resolveSquadPaths(TMP); + expect(result).not.toBeNull(); + expect(result!.mode).toBe('remote'); + expect(result!.teamDir).toBe(join(TMP, 'shared-team')); + }); + + it('returns null when .git exists but no .squad/ and no shared match', () => { + // .git boundary but no .squad/ and no matching shared registry + scaffold('.git', 'some-dir'); + expect(resolveSquadPaths(join(TMP, 'some-dir'))).toBeNull(); + }); + + // ──── SQUAD_REPO_KEY — direct key lookup ──── + + it('SQUAD_REPO_KEY: resolves shared mode by key', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + vi.stubEnv('SQUAD_REPO_KEY', 'testorg/testrepo'); + + // Set up git repo (no origin needed for key-based lookup) + initGitRepoWithOrigin(repoDir, 'https://github.com/testorg/testrepo.git'); + + // Set up registry and team dir + writeRegistry(appdataDir, [{ key: 'testorg/testrepo', urlPatterns: ['github.com/testorg/testrepo'] }]); + createTeamDir(appdataDir, 'testorg/testrepo'); + + const result = resolveSquadPaths(repoDir); + expect(result).not.toBeNull(); + expect(result!.mode).toBe('shared'); + expect(result!.teamDir).toBe(join(appdataDir, 'squad', 'repos', 'testorg', 'testrepo')); + expect(result!.config).toBeNull(); + expect(result!.name).toBe('.squad'); + expect(result!.isLegacy).toBe(false); + }); + + it('SQUAD_REPO_KEY: returns null when key not in registry', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + vi.stubEnv('SQUAD_REPO_KEY', 'testorg/nonexistent'); + + initGitRepoWithOrigin(repoDir, 'https://github.com/testorg/testrepo.git'); + writeRegistry(appdataDir, [{ key: 'testorg/testrepo', urlPatterns: [] }]); + + const result = resolveSquadPaths(repoDir); + expect(result).toBeNull(); + }); + + it('SQUAD_REPO_KEY: throws on invalid key format', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + vi.stubEnv('SQUAD_REPO_KEY', '../../../etc/passwd'); + + initGitRepoWithOrigin(repoDir, 'https://github.com/testorg/testrepo.git'); + mkdirSync(join(appdataDir, 'squad'), { recursive: true }); + + expect(() => resolveSquadPaths(repoDir)).toThrow(/path traversal/i); + }); + + it('SQUAD_REPO_KEY: returns null when no registry exists', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + vi.stubEnv('SQUAD_REPO_KEY', 'testorg/testrepo'); + + initGitRepoWithOrigin(repoDir, 'https://github.com/testorg/testrepo.git'); + // No registry file — just the global squad dir + mkdirSync(join(appdataDir, 'squad'), { recursive: true }); + + const result = resolveSquadPaths(repoDir); + expect(result).toBeNull(); + }); + + it('SQUAD_REPO_KEY: 3-segment key works (org/project/repo)', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + vi.stubEnv('SQUAD_REPO_KEY', 'testorg/testproject/testrepo'); + + initGitRepoWithOrigin(repoDir, 'https://dev.azure.com/testorg/testproject/_git/testrepo'); + + writeRegistry(appdataDir, [{ + key: 'testorg/testproject/testrepo', + urlPatterns: ['dev.azure.com/testorg/testproject/_git/testrepo'], + }]); + createTeamDir(appdataDir, 'testorg/testproject/testrepo'); + + const result = resolveSquadPaths(repoDir); + expect(result).not.toBeNull(); + expect(result!.mode).toBe('shared'); + expect(result!.teamDir).toBe( + join(appdataDir, 'squad', 'repos', 'testorg', 'testproject', 'testrepo'), + ); + }); + + it('SQUAD_REPO_KEY: local .squad/ takes precedence over SQUAD_REPO_KEY', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + vi.stubEnv('SQUAD_REPO_KEY', 'testorg/testrepo'); + + // Git repo WITH .squad/ directory (with agents/ marker) + initGitRepoWithOrigin(repoDir, 'https://github.com/testorg/testrepo.git'); + mkdirSync(join(repoDir, '.squad', 'agents'), { recursive: true }); + + writeRegistry(appdataDir, [{ key: 'testorg/testrepo', urlPatterns: [] }]); + createTeamDir(appdataDir, 'testorg/testrepo'); + + const result = resolveSquadPaths(repoDir); + expect(result).not.toBeNull(); + // Should be local mode, not shared — local .squad/ wins + expect(result!.mode).toBe('local'); + expect(result!.projectDir).toBe(join(repoDir, '.squad')); + }); + + // ──── URL-based discovery ──── + + it('URL discovery: resolves shared mode via origin remote', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + + initGitRepoWithOrigin(repoDir, 'https://github.com/myorg/myrepo.git'); + + writeRegistry(appdataDir, [{ + key: 'myorg/myrepo', + urlPatterns: ['github.com/myorg/myrepo'], + }]); + createTeamDir(appdataDir, 'myorg/myrepo'); + + const result = resolveSquadPaths(repoDir); + expect(result).not.toBeNull(); + expect(result!.mode).toBe('shared'); + expect(result!.teamDir).toBe(join(appdataDir, 'squad', 'repos', 'myorg', 'myrepo')); + }); + + it('URL discovery: returns null when origin URL not in registry', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + + initGitRepoWithOrigin(repoDir, 'https://github.com/unknown/repo.git'); + + writeRegistry(appdataDir, [{ + key: 'myorg/myrepo', + urlPatterns: ['github.com/myorg/myrepo'], + }]); + + const result = resolveSquadPaths(repoDir); + expect(result).toBeNull(); + }); + + it('URL discovery: works from nested subdirectory', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + + initGitRepoWithOrigin(repoDir, 'https://github.com/myorg/myrepo.git'); + mkdirSync(join(repoDir, 'packages', 'app', 'src'), { recursive: true }); + + writeRegistry(appdataDir, [{ + key: 'myorg/myrepo', + urlPatterns: ['github.com/myorg/myrepo'], + }]); + createTeamDir(appdataDir, 'myorg/myrepo'); + + const result = resolveSquadPaths(join(repoDir, 'packages', 'app', 'src')); + expect(result).not.toBeNull(); + expect(result!.mode).toBe('shared'); + }); + + it('URL discovery: SSH remote URL matches', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + + initGitRepoWithOrigin(repoDir, 'git@github.com:myorg/myrepo.git'); + + writeRegistry(appdataDir, [{ + key: 'myorg/myrepo', + urlPatterns: ['github.com/myorg/myrepo'], + }]); + createTeamDir(appdataDir, 'myorg/myrepo'); + + const result = resolveSquadPaths(repoDir); + expect(result).not.toBeNull(); + expect(result!.mode).toBe('shared'); + }); + + // ──── SQUAD_APPDATA_OVERRIDE ──── + + it('SQUAD_APPDATA_OVERRIDE: logs warning when set', () => { + _resetAppdataOverrideWarned(); + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + + initGitRepoWithOrigin(repoDir, 'https://github.com/testorg/testrepo.git'); + mkdirSync(join(appdataDir, 'squad'), { recursive: true }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + resolveSquadPaths(repoDir); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('SQUAD_APPDATA_OVERRIDE'), + ); + + warnSpy.mockRestore(); + }); + + it('SQUAD_APPDATA_OVERRIDE: uses override path for registry', () => { + const customAppdata = join(TMP, 'custom-appdata'); + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', customAppdata); + vi.stubEnv('SQUAD_REPO_KEY', 'testorg/testrepo'); + + initGitRepoWithOrigin(repoDir, 'https://github.com/testorg/testrepo.git'); + + writeRegistry(customAppdata, [{ key: 'testorg/testrepo', urlPatterns: [] }]); + createTeamDir(customAppdata, 'testorg/testrepo'); + + const result = resolveSquadPaths(repoDir); + expect(result).not.toBeNull(); + expect(result!.mode).toBe('shared'); + // teamDir should be under the custom appdata path + expect(result!.teamDir).toBe( + join(customAppdata, 'squad', 'repos', 'testorg', 'testrepo'), + ); + }); + + // ──── %APPDATA% unreachable (F11) ──── + + it('throws SquadError when global squad path is unreachable', () => { + // Point APPDATA to a path that will fail on mkdirSync + // Use a path with illegal characters or a non-existent drive + const badPath = join(TMP, 'nonexistent', '\0illegal'); + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', badPath); + + initGitRepoWithOrigin(repoDir, 'https://github.com/testorg/testrepo.git'); + + try { + resolveSquadPaths(repoDir); + // If we get here, the path happened to succeed — skip assertion + // (can happen on some platforms where null byte handling differs) + } catch (err) { + expect(err).toBeInstanceOf(SquadError); + expect((err as SquadError).message).toMatch(/roaming profile may be offline/i); + expect((err as SquadError).category).toBe('configuration'); + } + }); + + // ──── Shared mode result shape ──── + + it('shared mode result has correct shape', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + vi.stubEnv('SQUAD_REPO_KEY', 'testorg/testrepo'); + + initGitRepoWithOrigin(repoDir, 'https://github.com/testorg/testrepo.git'); + writeRegistry(appdataDir, [{ key: 'testorg/testrepo', urlPatterns: [] }]); + createTeamDir(appdataDir, 'testorg/testrepo'); + + const result = resolveSquadPaths(repoDir); + expect(result).not.toBeNull(); + expect(result!.mode).toBe('shared'); + expect(result!.config).toBeNull(); + expect(result!.name).toBe('.squad'); + expect(result!.isLegacy).toBe(false); + // projectDir should be a clone-state dir (under LOCALAPPDATA) + expect(typeof result!.projectDir).toBe('string'); + expect(result!.projectDir.length).toBeGreaterThan(0); + // teamDir should be under appdata + expect(result!.teamDir).toContain('repos'); + }); + + // ──── Edge cases ──── + + it('git repo with no origin remote returns null', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + + // Create a git repo with NO remotes + mkdirSync(repoDir, { recursive: true }); + execSync('git init', { cwd: repoDir, stdio: 'pipe' }); + + writeRegistry(appdataDir, [{ key: 'testorg/testrepo', urlPatterns: [] }]); + + const result = resolveSquadPaths(repoDir); + expect(result).toBeNull(); + }); + + it('worktree with no .squad/ falls back to shared mode', () => { + vi.stubEnv('SQUAD_APPDATA_OVERRIDE', appdataDir); + vi.stubEnv('SQUAD_REPO_KEY', 'testorg/testrepo'); + + // Simulate a worktree by creating .git as a file + mkdirSync(repoDir, { recursive: true }); + writeFileSync(join(repoDir, '.git'), 'gitdir: /somewhere/.git/worktrees/feature'); + + writeRegistry(appdataDir, [{ key: 'testorg/testrepo', urlPatterns: [] }]); + createTeamDir(appdataDir, 'testorg/testrepo'); + + const result = resolveSquadPaths(repoDir); + expect(result).not.toBeNull(); + // .git file means findGitRoot finds it — shared mode should work + expect(result!.mode).toBe('shared'); + }); +}); diff --git a/test/resolution.test.ts b/test/resolution.test.ts index f0c486baa..1b0f546e8 100644 --- a/test/resolution.test.ts +++ b/test/resolution.test.ts @@ -28,7 +28,7 @@ describe('resolveSquad()', () => { }); it('returns path when .squad/ exists at startDir', () => { - scaffold('.git', '.squad'); + scaffold('.git', '.squad', '.squad/agents'); expect(resolveSquad(TMP)).toBe(join(TMP, '.squad')); }); @@ -38,7 +38,7 @@ describe('resolveSquad()', () => { }); it('walks up and finds .squad/ in parent', () => { - scaffold('.git', '.squad', 'packages', 'packages/app'); + scaffold('.git', '.squad', '.squad/agents', 'packages', 'packages/app'); expect(resolveSquad(join(TMP, 'packages', 'app'))).toBe(join(TMP, '.squad')); }); @@ -57,7 +57,7 @@ describe('resolveSquad()', () => { }); it('finds .squad in worktree that has it', () => { - scaffold('repo/.squad', 'repo/src'); + scaffold('repo/.squad', 'repo/.squad/agents', 'repo/src'); writeFileSync(join(TMP, 'repo', '.git'), 'gitdir: /somewhere/.git/worktrees/repo'); expect(resolveSquad(join(TMP, 'repo', 'src'))).toBe(join(TMP, 'repo', '.squad')); }); @@ -65,7 +65,7 @@ describe('resolveSquad()', () => { it('falls back to main checkout .squad/ when worktree has none', () => { // main checkout: TMP/main with .git dir + .squad dir mkdirSync(join(TMP, 'main', '.git'), { recursive: true }); - mkdirSync(join(TMP, 'main', '.squad'), { recursive: true }); + mkdirSync(join(TMP, 'main', '.squad', 'agents'), { recursive: true }); // worktree: TMP/main/.worktrees/feature with .git FILE mkdirSync(join(TMP, 'main', '.worktrees', 'feature', 'src'), { recursive: true }); writeFileSync( @@ -80,9 +80,9 @@ describe('resolveSquad()', () => { it('prefers worktree-local .squad/ over main checkout when both exist', () => { // main checkout with .squad/ mkdirSync(join(TMP, 'main', '.git'), { recursive: true }); - mkdirSync(join(TMP, 'main', '.squad'), { recursive: true }); + mkdirSync(join(TMP, 'main', '.squad', 'agents'), { recursive: true }); // worktree with its own .squad/ - mkdirSync(join(TMP, 'main', '.worktrees', 'feature', '.squad'), { recursive: true }); + mkdirSync(join(TMP, 'main', '.worktrees', 'feature', '.squad', 'agents'), { recursive: true }); mkdirSync(join(TMP, 'main', '.worktrees', 'feature', 'src'), { recursive: true }); writeFileSync( join(TMP, 'main', '.worktrees', 'feature', '.git'), @@ -100,18 +100,18 @@ describe('resolveSquad()', () => { }); it('finds .squad/ at root from a deeply nested directory (3+ levels)', () => { - scaffold('.git', '.squad', 'a/b/c/d'); + scaffold('.git', '.squad', '.squad/agents', 'a/b/c/d'); expect(resolveSquad(join(TMP, 'a', 'b', 'c', 'd'))).toBe(join(TMP, '.squad')); }); it('finds the nearest .squad/ when multiple exist', () => { - scaffold('.git', '.squad', 'packages/.squad', 'packages/app'); + scaffold('.git', '.squad', '.squad/agents', 'packages/.squad', 'packages/.squad/agents', 'packages/app'); // Starting from packages/app, the nearest .squad/ is packages/.squad expect(resolveSquad(join(TMP, 'packages', 'app'))).toBe(join(TMP, 'packages', '.squad')); }); it('finds root .squad/ when no closer one exists', () => { - scaffold('.git', '.squad', 'packages/app/src'); + scaffold('.git', '.squad', '.squad/agents', 'packages/app/src'); expect(resolveSquad(join(TMP, 'packages', 'app', 'src'))).toBe(join(TMP, '.squad')); }); @@ -121,7 +121,7 @@ describe('resolveSquad()', () => { return; } const { symlinkSync } = require('node:fs') as typeof import('node:fs'); - scaffold('.git', 'real-squad', 'project/src'); + scaffold('.git', 'real-squad', 'real-squad/agents', 'project/src'); symlinkSync(join(TMP, 'real-squad'), join(TMP, 'project', '.squad')); expect(resolveSquad(join(TMP, 'project', 'src'))).toBe(join(TMP, 'project', '.squad')); }); diff --git a/test/scribe-merge.test.ts b/test/scribe-merge.test.ts new file mode 100644 index 000000000..1fcdbce3b --- /dev/null +++ b/test/scribe-merge.test.ts @@ -0,0 +1,425 @@ +/** + * Tests for scribe-merge — Scribe inbox merge claim protocol. + * + * Covers: happy path, concurrent claim simulation, crash recovery, + * content dedup, empty inbox, timestamp sorting, convenience wrappers. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, readFileSync, existsSync, renameSync, unlinkSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomBytes } from 'node:crypto'; +import { + mergeInbox, + recoverStaleProcessing, + mergeDecisionsInbox, + mergeAgentHistoryInbox, + mergeAllHistoryInboxes, +} from '@bradygaster/squad-sdk/scribe-merge'; +import type { ResolvedSquadPaths } from '@bradygaster/squad-sdk/resolution'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a unique temp directory for each test. */ +function makeTempDir(): string { + const dir = join(tmpdir(), 'squad-scribe-test-' + randomBytes(6).toString('hex')); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function makePaths(teamDir: string): ResolvedSquadPaths { + return { + mode: 'local', + projectDir: teamDir, + teamDir, + personalDir: null, + config: null, + name: '.squad', + isLegacy: false, + }; +} + +function writeInboxFile(inboxDir: string, filename: string, content: string): void { + mkdirSync(inboxDir, { recursive: true }); + writeFileSync(join(inboxDir, filename), content, 'utf-8'); +} + +// --------------------------------------------------------------------------- +// Core mergeInbox +// --------------------------------------------------------------------------- + +describe('mergeInbox', () => { + let root: string; + let inboxDir: string; + let canonicalFile: string; + + beforeEach(() => { + root = makeTempDir(); + inboxDir = join(root, 'decisions', 'inbox'); + canonicalFile = join(root, 'decisions.md'); + mkdirSync(inboxDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('happy path — merges 3 inbox files into canonical in timestamp order', () => { + writeInboxFile(inboxDir, 'flight-2025-07-22T10-05-00Z-aaaa0001.md', + '### Decision A\nFirst decision'); + writeInboxFile(inboxDir, 'eecom-2025-07-22T10-03-00Z-bbbb0002.md', + '### Decision B\nSecond decision (earlier timestamp)'); + writeInboxFile(inboxDir, 'scribe-2025-07-22T10-07-00Z-cccc0003.md', + '### Decision C\nThird decision'); + + const result = mergeInbox(inboxDir, canonicalFile); + + expect(result.merged).toBe(3); + expect(result.skipped).toBe(0); + expect(result.errors).toHaveLength(0); + + const content = readFileSync(canonicalFile, 'utf-8'); + const idx = { + b: content.indexOf('Decision B'), + a: content.indexOf('Decision A'), + c: content.indexOf('Decision C'), + }; + // Sorted by timestamp: B (10:03) < A (10:05) < C (10:07) + expect(idx.b).toBeLessThan(idx.a); + expect(idx.a).toBeLessThan(idx.c); + }); + + it('empty inbox — returns zeroed result', () => { + const result = mergeInbox(inboxDir, canonicalFile); + + expect(result.merged).toBe(0); + expect(result.skipped).toBe(0); + expect(result.errors).toHaveLength(0); + }); + + it('missing inbox dir — returns zeroed result (no crash)', () => { + const missing = join(root, 'nonexistent', 'inbox'); + const result = mergeInbox(missing, canonicalFile); + + expect(result.merged).toBe(0); + expect(result.skipped).toBe(0); + expect(result.errors).toHaveLength(0); + }); + + it('appends to existing canonical content', () => { + writeFileSync(canonicalFile, + '### Existing Decision\nPre-existing content\n'); + writeInboxFile(inboxDir, 'flight-2025-07-22T10-05-00Z-dddd0004.md', + '### New Decision\nNew content'); + + const result = mergeInbox(inboxDir, canonicalFile); + + expect(result.merged).toBe(1); + const content = readFileSync(canonicalFile, 'utf-8'); + expect(content).toContain('Existing Decision'); + expect(content).toContain('New Decision'); + // Existing must come before new + expect(content.indexOf('Existing Decision')).toBeLessThan( + content.indexOf('New Decision'), + ); + }); + + it('dedup — skips entry already in canonical file', () => { + const entry = '### Repeated Decision\nSame content here'; + writeFileSync(canonicalFile, entry + '\n'); + writeInboxFile(inboxDir, 'flight-2025-07-22T10-05-00Z-eeee0005.md', entry); + + const result = mergeInbox(inboxDir, canonicalFile); + + expect(result.merged).toBe(0); + expect(result.skipped).toBe(1); + // Canonical unchanged (no double-append) + const content = readFileSync(canonicalFile, 'utf-8'); + const occurrences = content.split('Repeated Decision').length - 1; + expect(occurrences).toBe(1); + }); + + it('dedup — skips empty inbox files', () => { + writeInboxFile(inboxDir, 'flight-2025-07-22T10-05-00Z-ffff0006.md', ' \n '); + + const result = mergeInbox(inboxDir, canonicalFile); + + expect(result.merged).toBe(0); + expect(result.skipped).toBe(1); + }); + + it('concurrent claim simulation — skips file claimed by another Scribe', () => { + writeInboxFile(inboxDir, 'flight-2025-07-22T10-05-00Z-1111aaaa.md', + '### Decision 1\nContent 1'); + writeInboxFile(inboxDir, 'eecom-2025-07-22T10-06-00Z-2222bbbb.md', + '### Decision 2\nContent 2'); + + // Simulate another Scribe claiming file 1 before our merge runs: + // move it out of inbox before calling mergeInbox + const processingDir = join(root, 'decisions', 'processing'); + mkdirSync(processingDir, { recursive: true }); + renameSync( + join(inboxDir, 'flight-2025-07-22T10-05-00Z-1111aaaa.md'), + join(processingDir, 'flight-2025-07-22T10-05-00Z-1111aaaa.md'), + ); + + const result = mergeInbox(inboxDir, canonicalFile); + + // Both files should be merged: the one we claimed from inbox (file 2) + // and the pre-existing one in processing/ (file 1, from crash/other Scribe) + expect(result.merged).toBe(2); + expect(result.errors).toHaveLength(0); + }); + + it('crash recovery — pre-existing processing/ files are included in merge', () => { + const processingDir = join(root, 'decisions', 'processing'); + mkdirSync(processingDir, { recursive: true }); + writeFileSync( + join(processingDir, 'stale-2025-07-22T09-00-00Z-aabbccdd.md'), + '### Stale Entry\nFrom a crashed Scribe', + ); + + const result = mergeInbox(inboxDir, canonicalFile); + + expect(result.merged).toBe(1); + const content = readFileSync(canonicalFile, 'utf-8'); + expect(content).toContain('Stale Entry'); + }); + + it('processing/ files already in canonical are skipped and deleted', () => { + const entry = '### Already Merged\nThis was already merged'; + writeFileSync(canonicalFile, entry + '\n'); + const processingDir = join(root, 'decisions', 'processing'); + mkdirSync(processingDir, { recursive: true }); + writeFileSync( + join(processingDir, 'dup-2025-07-22T09-00-00Z-11223344.md'), + entry, + ); + + const result = mergeInbox(inboxDir, canonicalFile); + + expect(result.merged).toBe(0); + expect(result.skipped).toBe(1); + // Processing file should be cleaned up + expect(existsSync(join(processingDir, 'dup-2025-07-22T09-00-00Z-11223344.md'))).toBe(false); + }); + + it('dryRun — returns counts without writing', () => { + writeInboxFile(inboxDir, 'flight-2025-07-22T10-05-00Z-dry10001.md', + '### Dry Run Entry\nShould not be written'); + + const result = mergeInbox(inboxDir, canonicalFile, { dryRun: true }); + + expect(result.merged).toBe(1); + expect(existsSync(canonicalFile)).toBe(false); + // File should still be in processing (not deleted in dry run) + const processingDir = join(root, 'decisions', 'processing'); + expect(existsSync(join(processingDir, 'flight-2025-07-22T10-05-00Z-dry10001.md'))).toBe(true); + }); + + it('non-.md files in inbox are ignored', () => { + writeInboxFile(inboxDir, 'readme.txt', 'not a markdown file'); + writeInboxFile(inboxDir, 'flight-2025-07-22T10-05-00Z-txt00001.md', + '### Valid Entry\nContent'); + + const result = mergeInbox(inboxDir, canonicalFile); + + expect(result.merged).toBe(1); + // txt file untouched + expect(existsSync(join(inboxDir, 'readme.txt'))).toBe(true); + }); + + it('processing/ directory is removed when empty after merge', () => { + writeInboxFile(inboxDir, 'flight-2025-07-22T10-05-00Z-rm000001.md', + '### Entry\nContent'); + + mergeInbox(inboxDir, canonicalFile); + + const processingDir = join(root, 'decisions', 'processing'); + expect(existsSync(processingDir)).toBe(false); + }); + + it('filenames without valid timestamps sort to front', () => { + writeInboxFile(inboxDir, 'bad-filename.md', + '### Bad Filename Entry\nNo timestamp'); + writeInboxFile(inboxDir, 'flight-2025-07-22T10-05-00Z-sort0001.md', + '### Good Filename Entry\nHas timestamp'); + + const result = mergeInbox(inboxDir, canonicalFile); + + expect(result.merged).toBe(2); + const content = readFileSync(canonicalFile, 'utf-8'); + // Bad filename (epoch 0) sorts before good filename + expect(content.indexOf('Bad Filename')).toBeLessThan( + content.indexOf('Good Filename'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// recoverStaleProcessing +// --------------------------------------------------------------------------- + +describe('recoverStaleProcessing', () => { + let root: string; + + beforeEach(() => { + root = makeTempDir(); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('moves stale files back to inbox', () => { + const processingDir = join(root, 'decisions', 'processing'); + const inboxDir = join(root, 'decisions', 'inbox'); + mkdirSync(processingDir, { recursive: true }); + const filePath = join(processingDir, 'stale-2025-07-22T09-00-00Z-aabb0001.md'); + writeFileSync(filePath, '### Stale\nContent'); + + // maxAgeMinutes=0 means anything older than now is stale + const recovered = recoverStaleProcessing(processingDir, 0); + + expect(recovered).toBe(1); + expect(existsSync(join(inboxDir, 'stale-2025-07-22T09-00-00Z-aabb0001.md'))).toBe(true); + expect(existsSync(filePath)).toBe(false); + }); + + it('leaves recent files in processing', () => { + const processingDir = join(root, 'decisions', 'processing'); + mkdirSync(processingDir, { recursive: true }); + const filePath = join(processingDir, 'recent-2025-07-22T09-00-00Z-ccdd0001.md'); + writeFileSync(filePath, '### Recent\nContent'); + + // maxAgeMinutes=9999 means nothing is stale + const recovered = recoverStaleProcessing(processingDir, 9999); + + expect(recovered).toBe(0); + expect(existsSync(filePath)).toBe(true); + }); + + it('returns 0 for missing processing directory', () => { + const missing = join(root, 'nonexistent', 'processing'); + const recovered = recoverStaleProcessing(missing); + expect(recovered).toBe(0); + }); + + it('returns 0 for empty processing directory', () => { + const processingDir = join(root, 'decisions', 'processing'); + mkdirSync(processingDir, { recursive: true }); + const recovered = recoverStaleProcessing(processingDir); + expect(recovered).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Convenience wrappers +// --------------------------------------------------------------------------- + +describe('mergeDecisionsInbox', () => { + let root: string; + + beforeEach(() => { + root = makeTempDir(); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('merges decisions/inbox/ into decisions.md via ResolvedSquadPaths', () => { + const paths = makePaths(root); + const inboxDir = join(paths.teamDir, 'decisions', 'inbox'); + mkdirSync(inboxDir, { recursive: true }); + writeFileSync( + join(inboxDir, 'flight-2025-07-22T10-05-00Z-dec00001.md'), + '### Team Decision\nWe decided a thing', + ); + + const result = mergeDecisionsInbox(paths); + + expect(result.merged).toBe(1); + const content = readFileSync(join(paths.teamDir, 'decisions.md'), 'utf-8'); + expect(content).toContain('Team Decision'); + }); +}); + +describe('mergeAgentHistoryInbox', () => { + let root: string; + + beforeEach(() => { + root = makeTempDir(); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('merges agent history inbox into history.md', () => { + const paths = makePaths(root); + const inboxDir = join(paths.teamDir, 'agents', 'flight', 'history', 'inbox'); + mkdirSync(inboxDir, { recursive: true }); + writeFileSync( + join(inboxDir, 'flight-2025-07-22T10-05-00Z-hist0001.md'), + '### Session learning\nLearned something', + ); + + const result = mergeAgentHistoryInbox(paths, 'flight'); + + expect(result.merged).toBe(1); + const content = readFileSync(join(paths.teamDir, 'agents', 'flight', 'history.md'), 'utf-8'); + expect(content).toContain('Session learning'); + }); +}); + +describe('mergeAllHistoryInboxes', () => { + let root: string; + + beforeEach(() => { + root = makeTempDir(); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('merges history inboxes for all agents with inbox dirs', () => { + const paths = makePaths(root); + + // Agent 1: flight — has inbox + const flightInbox = join(paths.teamDir, 'agents', 'flight', 'history', 'inbox'); + mkdirSync(flightInbox, { recursive: true }); + writeFileSync( + join(flightInbox, 'flight-2025-07-22T10-05-00Z-all00001.md'), + '### Flight learning\nContent', + ); + + // Agent 2: eecom — has inbox + const eecomInbox = join(paths.teamDir, 'agents', 'eecom', 'history', 'inbox'); + mkdirSync(eecomInbox, { recursive: true }); + writeFileSync( + join(eecomInbox, 'eecom-2025-07-22T10-06-00Z-all00002.md'), + '### EECOM learning\nContent', + ); + + // Agent 3: scribe — no inbox (should be skipped) + mkdirSync(join(root, 'agents', 'scribe'), { recursive: true }); + + const results = mergeAllHistoryInboxes(paths); + + expect(results.size).toBe(2); + expect(results.get('flight')?.merged).toBe(1); + expect(results.get('eecom')?.merged).toBe(1); + expect(results.has('scribe')).toBe(false); + }); + + it('returns empty map when agents/ dir is missing', () => { + const paths = makePaths(root); + const results = mergeAllHistoryInboxes(paths); + expect(results.size).toBe(0); + }); +}); diff --git a/test/sdk-feature-parity.test.ts b/test/sdk-feature-parity.test.ts index becb1ef6a..91b1ca08e 100644 --- a/test/sdk-feature-parity.test.ts +++ b/test/sdk-feature-parity.test.ts @@ -59,8 +59,9 @@ describe('SDK Feature: Worktree Awareness', () => { // Put .git at root mkdirSync(join(testRoot, '.git')); - // Put .squad/ at root + // Put .squad/ at root with a team.md so resolveSquad() recognizes it mkdirSync(join(testRoot, '.squad')); + writeFileSync(join(testRoot, '.squad', 'team.md'), '# Team\n'); // Resolve from deep subdirectory const result = resolveSquad(subdir); @@ -73,6 +74,7 @@ describe('SDK Feature: Worktree Awareness', () => { it('resolveSquadPaths() handles local mode (no config.json)', () => { const testRoot = join(tmpdir(), `squad-test-${Date.now()}`); mkdirSync(join(testRoot, '.squad'), { recursive: true }); + writeFileSync(join(testRoot, '.squad', 'team.md'), '# Team\n'); const result = resolveSquadPaths(testRoot); diff --git a/test/shared-squad.test.ts b/test/shared-squad.test.ts new file mode 100644 index 000000000..fb8b8be26 --- /dev/null +++ b/test/shared-squad.test.ts @@ -0,0 +1,546 @@ +/** + * Tests for shared-squad.ts — repo key validation, write path validation, + * journal filename sanitization, and repo registry CRUD. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { join, resolve, sep } from 'node:path'; +import { mkdirSync, rmSync, existsSync, symlinkSync, readFileSync, writeFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { + validateRepoKey, + validateWritePath, + sanitizeJournalFilenameComponent, +} from '@bradygaster/squad-sdk/shared-squad'; + +// ============================================================================ +// validateRepoKey() +// ============================================================================ + +describe('validateRepoKey()', () => { + // ── Valid keys ────────────────────────────────────────────────────────── + describe('accepts valid keys', () => { + it('2-segment GitHub key', () => { + expect(() => validateRepoKey('microsoft/vscode')).not.toThrow(); + }); + + it('3-segment ADO key', () => { + expect(() => validateRepoKey('microsoft/os/os.2020')).not.toThrow(); + }); + + it('keys with dots, underscores, and hyphens', () => { + expect(() => validateRepoKey('my-org/my_repo.v2')).not.toThrow(); + }); + + it('single-character segments', () => { + expect(() => validateRepoKey('a/b')).not.toThrow(); + }); + + it('numeric segments', () => { + expect(() => validateRepoKey('org123/repo456')).not.toThrow(); + }); + }); + + // ── Path traversal ───────────────────────────────────────────────────── + describe('rejects path traversal', () => { + it('.. as a segment', () => { + expect(() => validateRepoKey('../etc/passwd')).toThrow(/path traversal/); + }); + + it('.. in the middle', () => { + expect(() => validateRepoKey('microsoft/../../../etc')).toThrow(/path traversal/); + }); + + it('.. at the end', () => { + expect(() => validateRepoKey('owner/repo/..')).toThrow(/path traversal/); + }); + }); + + // ── Absolute paths ───────────────────────────────────────────────────── + describe('rejects absolute paths', () => { + it('Unix absolute path', () => { + expect(() => validateRepoKey('/etc/passwd')).toThrow(/absolute paths/); + }); + + it('Windows drive letter', () => { + expect(() => validateRepoKey('C:\\Windows\\System32')).toThrow(/(absolute paths|illegal characters)/); + }); + + it('UNC path', () => { + expect(() => validateRepoKey('\\\\server\\share')).toThrow(/(absolute paths|illegal characters)/); + }); + }); + + // ── Null bytes ───────────────────────────────────────────────────────── + describe('rejects null bytes', () => { + it('null byte in segment', () => { + expect(() => validateRepoKey('owner/re\0po')).toThrow(/null byte/); + }); + }); + + // ── Windows-illegal characters ───────────────────────────────────────── + describe('rejects Windows-illegal filename characters', () => { + for (const char of ['<', '>', ':', '"', '|', '?', '*', '\\']) { + it(`rejects "${char}"`, () => { + expect(() => validateRepoKey(`owner/repo${char}name`)).toThrow(/illegal characters/); + }); + } + }); + + // ── Empty / malformed ────────────────────────────────────────────────── + describe('rejects empty or malformed keys', () => { + it('empty string', () => { + expect(() => validateRepoKey('')).toThrow(/empty string/); + }); + + it('single segment', () => { + expect(() => validateRepoKey('onlyone')).toThrow(/2-3 segments/); + }); + + it('four segments', () => { + expect(() => validateRepoKey('a/b/c/d')).toThrow(/2-3 segments/); + }); + + it('empty segment (double slash)', () => { + expect(() => validateRepoKey('microsoft//os.2020')).toThrow(/empty segment/); + }); + + it('leading slash creating empty segment', () => { + expect(() => validateRepoKey('/os/os.2020')).toThrow(/absolute paths/); + }); + + it('trailing slash creating empty segment', () => { + expect(() => validateRepoKey('os/os.2020/')).toThrow(/empty segment/); + }); + }); + + // ── Segment length ───────────────────────────────────────────────────── + describe('rejects oversized segments', () => { + it('segment exceeding 128 characters', () => { + const long = 'a'.repeat(129); + expect(() => validateRepoKey(`owner/${long}`)).toThrow(/exceeds 128/); + }); + + it('accepts segment at exactly 128 characters', () => { + const exact = 'a'.repeat(128); + expect(() => validateRepoKey(`owner/${exact}`)).not.toThrow(); + }); + }); + + // ── Character whitelist ──────────────────────────────────────────────── + describe('rejects characters outside whitelist', () => { + it('uppercase letters', () => { + expect(() => validateRepoKey('Microsoft/VSCode')).toThrow(/invalid characters/); + }); + + it('spaces', () => { + expect(() => validateRepoKey('my org/my repo')).toThrow(/invalid characters/); + }); + + it('@ symbol', () => { + expect(() => validateRepoKey('owner/@scoped-repo')).toThrow(/invalid characters/); + }); + }); +}); + +// ============================================================================ +// validateWritePath() +// ============================================================================ + +describe('validateWritePath()', () => { + const TMP = join(process.cwd(), `.test-write-path-${randomBytes(4).toString('hex')}`); + const ROOT = join(TMP, 'repos'); + + function setup() { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + mkdirSync(join(ROOT, 'microsoft', 'vscode'), { recursive: true }); + } + + function teardown() { + if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); + } + + describe('accepts paths inside root', () => { + it('existing directory', () => { + setup(); + try { + expect(() => + validateWritePath(join(ROOT, 'microsoft', 'vscode'), ROOT) + ).not.toThrow(); + } finally { + teardown(); + } + }); + + it('file that does not exist yet (parent exists)', () => { + setup(); + try { + expect(() => + validateWritePath(join(ROOT, 'microsoft', 'vscode', 'new-file.md'), ROOT) + ).not.toThrow(); + } finally { + teardown(); + } + }); + + it('deeply nested path where intermediate dirs do not exist', () => { + setup(); + try { + expect(() => + validateWritePath(join(ROOT, 'microsoft', 'vscode', 'deep', 'nested', 'file.md'), ROOT) + ).not.toThrow(); + } finally { + teardown(); + } + }); + }); + + describe('rejects paths outside root', () => { + it('path outside expected root via ..', () => { + setup(); + try { + expect(() => + validateWritePath(join(ROOT, '..', 'escape.txt'), ROOT) + ).toThrow(/escapes expected root/); + } finally { + teardown(); + } + }); + + it('completely unrelated path', () => { + setup(); + try { + // Use a path clearly outside the test root + const outsidePath = resolve(TMP, '..', 'somewhere-else', 'file.txt'); + expect(() => validateWritePath(outsidePath, ROOT)).toThrow(/escapes expected root/); + } finally { + teardown(); + } + }); + }); + + describe('rejects when expectedRoot does not exist', () => { + it('throws for non-existent root', () => { + expect(() => + validateWritePath('/some/file.txt', '/nonexistent/root') + ).toThrow(/does not exist/); + }); + }); + + // Symlink test — only run on platforms that support symlinks without admin + const canSymlink = process.platform !== 'win32'; + (canSymlink ? describe : describe.skip)('symlink escape detection', () => { + it('rejects path through symlink that escapes root', () => { + setup(); + const outsideDir = join(TMP, 'outside-target'); + mkdirSync(outsideDir, { recursive: true }); + const linkPath = join(ROOT, 'microsoft', 'evil-link'); + try { + symlinkSync(outsideDir, linkPath, 'dir'); + expect(() => + validateWritePath(join(linkPath, 'file.txt'), ROOT) + ).toThrow(/escapes expected root/); + } finally { + teardown(); + } + }); + }); +}); + +// ============================================================================ +// sanitizeJournalFilenameComponent() +// ============================================================================ + +describe('sanitizeJournalFilenameComponent()', () => { + it('passes through clean names', () => { + expect(sanitizeJournalFilenameComponent('retro')).toBe('retro'); + expect(sanitizeJournalFilenameComponent('flight-2')).toBe('flight-2'); + expect(sanitizeJournalFilenameComponent('Agent_1')).toBe('Agent_1'); + }); + + it('replaces dots', () => { + expect(sanitizeJournalFilenameComponent('agent.v2')).toBe('agent_v2'); + }); + + it('replaces path separators', () => { + expect(sanitizeJournalFilenameComponent('../../../etc/passwd')).toBe( + '_________etc_passwd' + ); + expect(sanitizeJournalFilenameComponent('agents\\evil')).toBe('agents_evil'); + }); + + it('replaces spaces and special characters', () => { + expect(sanitizeJournalFilenameComponent('my agent (v2)')).toBe('my_agent__v2_'); + }); + + it('handles empty string', () => { + expect(sanitizeJournalFilenameComponent('')).toBe(''); + }); + + it('replaces null bytes', () => { + expect(sanitizeJournalFilenameComponent('agent\0name')).toBe('agent_name'); + }); + + it('preserves uppercase letters', () => { + expect(sanitizeJournalFilenameComponent('RETRO')).toBe('RETRO'); + }); +}); + +// ============================================================================ +// Registry CRUD Tests +// ============================================================================ + +const REGISTRY_TMP = join(process.cwd(), `.test-registry-${randomBytes(4).toString('hex')}`); +const CLONE_TMP = join(process.cwd(), `.test-clone-${randomBytes(4).toString('hex')}`); + +/** + * Dynamically import shared-squad module to pick up env stubs. + */ +async function loadSharedSquadModule() { + return await import('@bradygaster/squad-sdk/shared-squad'); +} + +describe('Repo Registry CRUD', () => { + let fakeAppData: string; + let fakeLocalAppData: string; + + beforeEach(() => { + // Clean up + if (existsSync(REGISTRY_TMP)) rmSync(REGISTRY_TMP, { recursive: true, force: true }); + if (existsSync(CLONE_TMP)) rmSync(CLONE_TMP, { recursive: true, force: true }); + mkdirSync(REGISTRY_TMP, { recursive: true }); + mkdirSync(CLONE_TMP, { recursive: true }); + + fakeAppData = join(REGISTRY_TMP, 'appdata'); + fakeLocalAppData = join(REGISTRY_TMP, 'local-appdata'); + mkdirSync(fakeAppData, { recursive: true }); + mkdirSync(fakeLocalAppData, { recursive: true }); + + // Stub APPDATA so resolveGlobalSquadPath() uses our temp dir + vi.stubEnv('APPDATA', fakeAppData); + vi.stubEnv('LOCALAPPDATA', fakeLocalAppData); + // Linux/macOS fallback + vi.stubEnv('XDG_CONFIG_HOME', fakeAppData); + vi.stubEnv('XDG_DATA_HOME', fakeLocalAppData); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + if (existsSync(REGISTRY_TMP)) rmSync(REGISTRY_TMP, { recursive: true, force: true }); + if (existsSync(CLONE_TMP)) rmSync(CLONE_TMP, { recursive: true, force: true }); + }); + + // ── loadRepoRegistry ──────────────────────────────────────────────────── + + describe('loadRepoRegistry()', () => { + it('returns null when repos.json does not exist', async () => { + const { loadRepoRegistry } = await loadSharedSquadModule(); + expect(loadRepoRegistry()).toBeNull(); + }); + + it('returns null for malformed JSON', async () => { + const { loadRepoRegistry, saveRepoRegistry } = await loadSharedSquadModule(); + // First create the squad dir, then write garbage + const { resolveGlobalSquadPath } = await import('@bradygaster/squad-sdk/resolution'); + const globalDir = resolveGlobalSquadPath(); + writeFileSync(join(globalDir, 'repos.json'), '{ invalid json !!!'); + expect(loadRepoRegistry()).toBeNull(); + }); + + it('returns null for valid JSON with wrong shape', async () => { + const { loadRepoRegistry } = await loadSharedSquadModule(); + const { resolveGlobalSquadPath } = await import('@bradygaster/squad-sdk/resolution'); + const globalDir = resolveGlobalSquadPath(); + writeFileSync(join(globalDir, 'repos.json'), JSON.stringify({ foo: 'bar' })); + expect(loadRepoRegistry()).toBeNull(); + }); + + it('loads a valid registry', async () => { + const { loadRepoRegistry } = await loadSharedSquadModule(); + const { resolveGlobalSquadPath } = await import('@bradygaster/squad-sdk/resolution'); + const globalDir = resolveGlobalSquadPath(); + const registry = { + version: 1, + repos: [{ key: 'owner/repo', urlPatterns: ['github.com/owner/repo'], created_at: '2025-01-01T00:00:00Z' }], + }; + writeFileSync(join(globalDir, 'repos.json'), JSON.stringify(registry)); + const result = loadRepoRegistry(); + expect(result).not.toBeNull(); + expect(result!.version).toBe(1); + expect(result!.repos).toHaveLength(1); + expect(result!.repos[0]!.key).toBe('owner/repo'); + }); + }); + + // ── saveRepoRegistry ──────────────────────────────────────────────────── + + describe('saveRepoRegistry()', () => { + it('writes repos.json', async () => { + const { saveRepoRegistry, loadRepoRegistry } = await loadSharedSquadModule(); + const registry = { + version: 1 as const, + repos: [{ key: 'owner/repo', urlPatterns: ['github.com/owner/repo'], created_at: '2025-01-01T00:00:00Z' }], + }; + saveRepoRegistry(registry); + const loaded = loadRepoRegistry(); + expect(loaded).not.toBeNull(); + expect(loaded!.repos[0]!.key).toBe('owner/repo'); + }); + }); + + // ── createSharedSquad ─────────────────────────────────────────────────── + + describe('createSharedSquad()', () => { + it('creates team directory and registers in repos.json', async () => { + const { createSharedSquad, loadRepoRegistry } = await loadSharedSquadModule(); + const teamDir = createSharedSquad('owner/repo', ['github.com/owner/repo']); + expect(existsSync(teamDir)).toBe(true); + expect(existsSync(join(teamDir, 'manifest.json'))).toBe(true); + + const registry = loadRepoRegistry(); + expect(registry).not.toBeNull(); + expect(registry!.repos).toHaveLength(1); + expect(registry!.repos[0]!.key).toBe('owner/repo'); + }); + + it('creates 3-segment nested directories for ADO repos', async () => { + const { createSharedSquad } = await loadSharedSquadModule(); + const teamDir = createSharedSquad('microsoft/os/os.2020', ['dev.azure.com/microsoft/os/_git/os.2020']); + expect(existsSync(teamDir)).toBe(true); + // Verify nested structure + expect(teamDir).toContain(join('repos', 'microsoft', 'os', 'os.2020')); + }); + + it('writes manifest.json with correct content', async () => { + const { createSharedSquad } = await loadSharedSquadModule(); + const teamDir = createSharedSquad('owner/repo', ['github.com/owner/repo']); + const manifest = JSON.parse(readFileSync(join(teamDir, 'manifest.json'), 'utf-8')); + expect(manifest.version).toBe(1); + expect(manifest.repoKey).toBe('owner/repo'); + expect(manifest.urlPatterns).toEqual(['github.com/owner/repo']); + expect(manifest.created_at).toBeTruthy(); + }); + + it('throws for invalid repo key', async () => { + const { createSharedSquad } = await loadSharedSquadModule(); + expect(() => createSharedSquad('Invalid/Key', ['github.com/invalid/key'])) + .toThrow(/invalid characters/); + }); + + it('throws for duplicate repo key', async () => { + const { createSharedSquad } = await loadSharedSquadModule(); + createSharedSquad('owner/repo', ['github.com/owner/repo']); + expect(() => createSharedSquad('owner/repo', ['github.com/owner/repo'])) + .toThrow(/already exists/); + }); + }); + + // ── lookupByUrl ───────────────────────────────────────────────────────── + + describe('lookupByUrl()', () => { + it('returns null when registry is empty', async () => { + const { lookupByUrl } = await loadSharedSquadModule(); + expect(lookupByUrl('github.com/owner/repo')).toBeNull(); + }); + + it('finds entry by matching URL pattern', async () => { + const { createSharedSquad, lookupByUrl } = await loadSharedSquadModule(); + createSharedSquad('owner/repo', ['github.com/owner/repo']); + const result = lookupByUrl('github.com/owner/repo'); + expect(result).not.toBeNull(); + expect(result!.key).toBe('owner/repo'); + }); + + it('matches case-insensitively', async () => { + const { createSharedSquad, lookupByUrl } = await loadSharedSquadModule(); + createSharedSquad('owner/repo', ['github.com/owner/repo']); + const result = lookupByUrl('GitHub.com/Owner/Repo'); + expect(result).not.toBeNull(); + expect(result!.key).toBe('owner/repo'); + }); + + it('returns null for non-matching URL', async () => { + const { createSharedSquad, lookupByUrl } = await loadSharedSquadModule(); + createSharedSquad('owner/repo', ['github.com/owner/repo']); + expect(lookupByUrl('github.com/other/project')).toBeNull(); + }); + + it('matches against multiple URL patterns', async () => { + const { createSharedSquad, lookupByUrl } = await loadSharedSquadModule(); + createSharedSquad('microsoft/os/os.2020', [ + 'microsoft.visualstudio.com/os/_git/os.2020', + 'dev.azure.com/microsoft/os/_git/os.2020', + ]); + expect(lookupByUrl('dev.azure.com/microsoft/os/_git/os.2020')).not.toBeNull(); + expect(lookupByUrl('microsoft.visualstudio.com/os/_git/os.2020')).not.toBeNull(); + }); + }); + + // ── addUrlPattern ─────────────────────────────────────────────────────── + + describe('addUrlPattern()', () => { + it('adds a new URL pattern to an existing entry', async () => { + const { createSharedSquad, addUrlPattern, loadRepoRegistry } = await loadSharedSquadModule(); + createSharedSquad('microsoft/os/os.2020', ['microsoft.visualstudio.com/os/_git/os.2020']); + // Add a different normalized form (dev.azure.com variant) + addUrlPattern('microsoft/os/os.2020', 'https://dev.azure.com/microsoft/os/_git/os.2020'); + const registry = loadRepoRegistry(); + expect(registry!.repos[0]!.urlPatterns).toHaveLength(2); + expect(registry!.repos[0]!.urlPatterns).toContain('dev.azure.com/microsoft/os/_git/os.2020'); + }); + + it('does not add duplicate patterns', async () => { + const { createSharedSquad, addUrlPattern, loadRepoRegistry } = await loadSharedSquadModule(); + createSharedSquad('owner/repo', ['github.com/owner/repo']); + addUrlPattern('owner/repo', 'https://github.com/owner/repo'); + const registry = loadRepoRegistry(); + // Should stay at 1 since normalized form matches + expect(registry!.repos[0]!.urlPatterns).toHaveLength(1); + }); + + it('throws when registry does not exist', async () => { + const { addUrlPattern } = await loadSharedSquadModule(); + expect(() => addUrlPattern('owner/repo', 'github.com/owner/repo')) + .toThrow(/No repo registry found/); + }); + + it('throws when repo key is not found', async () => { + const { createSharedSquad, addUrlPattern } = await loadSharedSquadModule(); + createSharedSquad('owner/repo', ['github.com/owner/repo']); + expect(() => addUrlPattern('other/repo', 'github.com/other/repo')) + .toThrow(/not found in registry/); + }); + + it('also updates manifest.json', async () => { + const { createSharedSquad, addUrlPattern } = await loadSharedSquadModule(); + const teamDir = createSharedSquad('microsoft/os/os.2020', ['microsoft.visualstudio.com/os/_git/os.2020']); + addUrlPattern('microsoft/os/os.2020', 'https://dev.azure.com/microsoft/os/_git/os.2020'); + const manifest = JSON.parse(readFileSync(join(teamDir, 'manifest.json'), 'utf-8')); + expect(manifest.urlPatterns).toHaveLength(2); + }); + }); + + // ── resolveSharedSquad ────────────────────────────────────────────────── + + describe('resolveSharedSquad()', () => { + it('returns null when no origin remote exists', async () => { + const { resolveSharedSquad } = await loadSharedSquadModule(); + // A temp dir with no git repo + const noGitDir = join(REGISTRY_TMP, 'no-git'); + mkdirSync(noGitDir, { recursive: true }); + expect(resolveSharedSquad(noGitDir)).toBeNull(); + }); + + it('returns null when origin URL has no registry match', async () => { + const { resolveSharedSquad, createSharedSquad } = await loadSharedSquadModule(); + createSharedSquad('owner/repo', ['github.com/owner/repo']); + + // Create a fake git repo with a different origin + const fakeRepo = join(CLONE_TMP, 'fake-repo'); + mkdirSync(join(fakeRepo, '.git'), { recursive: true }); + // We can't easily fake `git remote get-url origin` without a real repo, + // so this will return null from getRemoteUrl (no git config) + expect(resolveSharedSquad(fakeRepo)).toBeNull(); + }); + }); +}); diff --git a/test/speed-gates.test.ts b/test/speed-gates.test.ts index 03c2b930c..42c15cdd7 100644 --- a/test/speed-gates.test.ts +++ b/test/speed-gates.test.ts @@ -41,7 +41,7 @@ describe('Speed: --help is scannable', { timeout: 30_000 }, () => { await harness.waitForExit(15000); const output = harness.captureFrame(); const lines = output.split('\n').filter(l => l.trim()); - expect(lines.length).toBeLessThanOrEqual(125); + expect(lines.length).toBeLessThanOrEqual(130); }); it('first 5 lines tell user what to do next', async () => { diff --git a/test/tools.test.ts b/test/tools.test.ts index 8cf3ac3fb..3a0b0f9b4 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ToolRegistry, defineTool, type RouteRequest, type DecisionRecord, type MemoryEntry } from '@bradygaster/squad-sdk/tools'; import { SessionPool } from '@bradygaster/squad-sdk/client'; +import type { ResolvedSquadPaths } from '@bradygaster/squad-sdk'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { randomUUID } from 'node:crypto'; @@ -264,7 +265,7 @@ describe('squad_decide handler', () => { const files = fs.readdirSync(inboxDir); expect(files.length).toBe(1); - expect(files[0]).toMatch(/^fenster-use-typescript-for-all-new-code\.md$/); + expect(files[0]).toMatch(/^fenster-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-[0-9a-f]{8}\.md$/); const content = fs.readFileSync(path.join(inboxDir, files[0]), 'utf-8'); expect(content).toContain('Use TypeScript for all new code'); @@ -343,7 +344,7 @@ Initial session entry. } }); - it('should append to existing section', async () => { + it('should write entry to history inbox (journal pattern)', async () => { const tool = registry.getTool('squad_memory')!; const result = await tool.handler( { @@ -363,22 +364,24 @@ Initial session entry. resultType: 'success', }); - const historyFile = path.join(testRoot, 'agents', 'fenster', 'history.md'); - const content = fs.readFileSync(historyFile, 'utf-8'); - - expect(content).toContain('Learned how to implement ToolRegistry'); - expect(content).toContain('## Learnings'); - - // Check it's in the right section - const learningsIndex = content.indexOf('## Learnings'); - const updatesIndex = content.indexOf('## Updates'); - const newEntryIndex = content.indexOf('Learned how to implement ToolRegistry'); - - expect(newEntryIndex).toBeGreaterThan(learningsIndex); - expect(newEntryIndex).toBeLessThan(updatesIndex); + // Verify inbox file was created instead of mutating history.md + const inboxDir = path.join(testRoot, 'agents', 'fenster', 'history', 'inbox'); + expect(fs.existsSync(inboxDir)).toBe(true); + + const files = fs.readdirSync(inboxDir); + expect(files.length).toBe(1); + expect(files[0]).toMatch(/^fenster-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-[0-9a-f]{8}\.md$/); + + const inboxContent = fs.readFileSync(path.join(inboxDir, files[0]), 'utf-8'); + expect(inboxContent).toContain('## Learnings'); + expect(inboxContent).toContain('Learned how to implement ToolRegistry'); + + // Verify history.md was NOT mutated + const historyContent = fs.readFileSync(path.join(testRoot, 'agents', 'fenster', 'history.md'), 'utf-8'); + expect(historyContent).not.toContain('Learned how to implement ToolRegistry'); }); - it('should create section if it does not exist', async () => { + it('should write journal entry with correct section header for new section', async () => { // Create a history file without Context section (sessions maps to Context via SECTION_MAP) const agentDir = path.join(testRoot, 'agents', 'brady'); fs.mkdirSync(agentDir, { recursive: true }); @@ -403,11 +406,15 @@ Initial session entry. resultType: 'success', }); - const historyFile = path.join(testRoot, 'agents', 'brady', 'history.md'); - const content = fs.readFileSync(historyFile, 'utf-8'); - - expect(content).toContain('## Context'); - expect(content).toContain('Session on M1-1 implementation'); + // Verify journal file in inbox + const inboxDir = path.join(testRoot, 'agents', 'brady', 'history', 'inbox'); + expect(fs.existsSync(inboxDir)).toBe(true); + const files = fs.readdirSync(inboxDir); + expect(files.length).toBe(1); + + const inboxContent = fs.readFileSync(path.join(inboxDir, files[0]), 'utf-8'); + expect(inboxContent).toContain('## Context'); + expect(inboxContent).toContain('Session on M1-1 implementation'); }); it('should fail if agent history does not exist', async () => { @@ -431,9 +438,142 @@ Initial session entry. error: 'History file does not exist', }); }); + it('should create separate inbox files for concurrent writes', async () => { + const tool = registry.getTool('squad_memory')!; + const callCtx = { + sessionId: 'test-session', + toolCallId: 'test-call', + toolName: 'squad_memory' as const, + arguments: {}, + }; + + // Write two entries concurrently + const [result1, result2] = await Promise.all([ + tool.handler( + { agent: 'fenster', section: 'learnings', content: 'First learning.' } as MemoryEntry, + callCtx, + ), + tool.handler( + { agent: 'fenster', section: 'learnings', content: 'Second learning.' } as MemoryEntry, + callCtx, + ), + ]); + + expect(result1).toMatchObject({ resultType: 'success' }); + expect(result2).toMatchObject({ resultType: 'success' }); + + const inboxDir = path.join(testRoot, 'agents', 'fenster', 'history', 'inbox'); + const files = fs.readdirSync(inboxDir); + expect(files.length).toBe(2); + + // Each file should have unique name + expect(files[0]).not.toBe(files[1]); + }); }); -describe('squad_status handler', () => { +describe('ToolRegistry with ResolvedSquadPaths', () => { + let testRoot: string; + + afterEach(() => { + if (fs.existsSync(testRoot)) { + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }); + + it('should use injected ResolvedSquadPaths for decision inbox path', async () => { + testRoot = path.join('.', '.test-squad-locator-' + randomUUID()); + const teamDir = path.join(testRoot, 'team'); + const projectDir = path.join(testRoot, 'project'); + + const resolvedPaths: ResolvedSquadPaths = { + mode: 'local', + projectDir, + teamDir, + personalDir: null, + config: null, + name: '.squad', + isLegacy: false, + }; + + const registry = new ToolRegistry(testRoot, undefined, undefined, undefined, resolvedPaths); + const tool = registry.getTool('squad_decide')!; + + const result = await tool.handler( + { + author: 'eecom', + summary: 'Test locator routing', + body: 'Decisions should go to teamDir.', + } as DecisionRecord, + { + sessionId: 'test-session', + toolCallId: 'test-call', + toolName: 'squad_decide', + arguments: {}, + } + ); + + expect(result.resultType).toBe('success'); + + // Verify file was written under teamDir, not testRoot + const inboxDir = path.join(teamDir, 'decisions', 'inbox'); + expect(fs.existsSync(inboxDir)).toBe(true); + const files = fs.readdirSync(inboxDir); + expect(files.length).toBe(1); + }); + + it('should use injected ResolvedSquadPaths for memory inbox path', async () => { + testRoot = path.join('.', '.test-squad-locator-mem-' + randomUUID()); + const teamDir = path.join(testRoot, 'team'); + const projectDir = path.join(testRoot, 'project'); + + const resolvedPaths: ResolvedSquadPaths = { + mode: 'local', + projectDir, + teamDir, + personalDir: null, + config: null, + name: '.squad', + isLegacy: false, + }; + + // Create the history file under teamDir (where squad_memory checks for it) + const agentDir = path.join(teamDir, 'agents', 'eecom'); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, 'history.md'), '# EECOM\n\n## Learnings\n', 'utf-8'); + + const registry = new ToolRegistry(testRoot, undefined, undefined, undefined, resolvedPaths); + const tool = registry.getTool('squad_memory')!; + + const result = await tool.handler( + { + agent: 'eecom', + section: 'learnings', + content: 'Locator routes inbox correctly.', + } as MemoryEntry, + { + sessionId: 'test-session', + toolCallId: 'test-call', + toolName: 'squad_memory', + arguments: {}, + } + ); + + expect(result.resultType).toBe('success'); + + // Verify file was written under teamDir, not testRoot + const inboxDir = path.join(teamDir, 'agents', 'eecom', 'history', 'inbox'); + expect(fs.existsSync(inboxDir)).toBe(true); + const files = fs.readdirSync(inboxDir); + expect(files.length).toBe(1); + expect(files[0]).toMatch(/^eecom-.*-[0-9a-f]{8}\.md$/); + }); + + it('should default to local-mode ResolvedSquadPaths when none provided', () => { + testRoot = path.join('.', '.test-squad-default-' + randomUUID()); + const registry = new ToolRegistry(testRoot); + // Should not throw — backward compatible + expect(registry.getTools().length).toBeGreaterThan(0); + }); let registry: ToolRegistry; let sessionPool: SessionPool; diff --git a/test/worktree.test.ts b/test/worktree.test.ts index fb0c51001..a0bae482f 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -78,6 +78,7 @@ describe('worktree regression (#521)', () => { const repo = join(tmp, 'repo'); mkdirSync(join(repo, '.git'), { recursive: true }); mkdirSync(join(repo, '.squad'), { recursive: true }); + writeFileSync(join(repo, '.squad', 'team.md'), '# Test Team\n'); mkdirSync(join(repo, 'src'), { recursive: true }); // resolveSquad() should find .squad/ before hitting the .git directory