From ca5b36a668dfbbdf210ce3e5482e9fc0914a5e14 Mon Sep 17 00:00:00 2001 From: Aaron Kubly Date: Tue, 21 Apr 2026 13:18:09 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20shared=20squad=20=E2=80=94=20extern?= =?UTF-8?q?al=20state=20backend=20for=20multi-clone=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable sharing a single squad team across multiple repository clones by storing team state externally. Supports git-backed squad repos with a ~/.squad/squad-repos.json pointer file, per-clone runtime state isolation, and concurrent-safe Scribe merge via claim protocol. Key components: - Registry CRUD and URL matching (shared-squad.ts) - Per-clone runtime state isolation (clone-state.ts) - Concurrent-safe Scribe merge with claim protocol (scribe-merge.ts) - CLI commands: squad shared status|add-url|list|doctor|diagnose - squad init --shared and squad migrate --to shared - Resolution chain: local → SQUAD_REPO_KEY → git-backed pointers → legacy - .squad/ validation: requires team.md or agents/ to be a team root Closes #958 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/shared-squad-external-state.md | 20 + package.json | 2 +- packages/squad-cli/package.json | 14 +- packages/squad-cli/src/cli-entry.ts | 57 +- packages/squad-cli/src/cli/commands/doctor.ts | 22 +- .../squad-cli/src/cli/commands/init-shared.ts | 234 +++++++ .../squad-cli/src/cli/commands/migrate.ts | 238 ++++++- packages/squad-cli/src/cli/commands/shared.ts | 532 ++++++++++++++ packages/squad-cli/src/cli/core/init.ts | 17 +- packages/squad-sdk/package.json | 12 + packages/squad-sdk/src/clone-state.ts | 269 +++++++ packages/squad-sdk/src/index.ts | 42 +- packages/squad-sdk/src/platform/detect.ts | 175 +++++ packages/squad-sdk/src/platform/index.ts | 4 +- packages/squad-sdk/src/resolution-base.ts | 158 +++++ packages/squad-sdk/src/resolution.ts | 291 +++++--- packages/squad-sdk/src/scribe-merge.ts | 411 +++++++++++ packages/squad-sdk/src/shared-squad.ts | 655 ++++++++++++++++++ .../src/storage/fs-storage-provider.ts | 4 +- packages/squad-sdk/src/tools/index.ts | 77 +- test/cli-global.test.ts | 7 +- test/cli/shared.test.ts | 352 ++++++++++ test/clone-state.test.ts | 309 +++++++++ test/dual-root-resolver.test.ts | 4 + test/integration.test.ts | 10 +- test/multi-squad.test.ts | 2 +- test/personal-squad-init.test.ts | 4 +- test/platform-adapter.test.ts | 228 ++++++ test/resolution-shared-mode.test.ts | 387 +++++++++++ test/resolution.test.ts | 20 +- test/scribe-merge.test.ts | 425 ++++++++++++ test/sdk-feature-parity.test.ts | 4 +- test/shared-squad.test.ts | 546 +++++++++++++++ test/speed-gates.test.ts | 2 +- test/tools.test.ts | 184 ++++- test/worktree.test.ts | 1 + 36 files changed, 5513 insertions(+), 206 deletions(-) create mode 100644 .changeset/shared-squad-external-state.md create mode 100644 packages/squad-cli/src/cli/commands/init-shared.ts create mode 100644 packages/squad-cli/src/cli/commands/shared.ts create mode 100644 packages/squad-sdk/src/clone-state.ts create mode 100644 packages/squad-sdk/src/resolution-base.ts create mode 100644 packages/squad-sdk/src/scribe-merge.ts create mode 100644 packages/squad-sdk/src/shared-squad.ts create mode 100644 test/cli/shared.test.ts create mode 100644 test/clone-state.test.ts create mode 100644 test/resolution-shared-mode.test.ts create mode 100644 test/scribe-merge.test.ts create mode 100644 test/shared-squad.test.ts 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/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..334dba6ba 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,15 @@ 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 { runInitShared } = await import('./cli/commands/init-shared.js'); + runInitShared(process.cwd(), key); + return; + } + if (mode === 'remote') { const teamPath = args[modeIdx + 2]; if (!teamPath) { @@ -357,11 +376,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 +717,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 +957,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..67818277b --- /dev/null +++ b/packages/squad-cli/src/cli/commands/init-shared.ts @@ -0,0 +1,234 @@ +/** + * 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, + loadRepoRegistry, + 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): 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 + const registry = loadRepoRegistry(); + const existing = registry?.repos.find(r => r.key === key); + if (existing) { + // Already registered — resolve teamDir and add URL pattern if new + let globalDir: string; + try { + globalDir = resolveGlobalSquadPath(); + } catch (err) { + fatal((err as Error).message); + } + const teamDir = path.join(globalDir, 'repos', ...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 ${path.join(globalDir, '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 { + 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]}`); + } + 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-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..42612501e 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -10,8 +10,46 @@ 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, + 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..e5d406ffa --- /dev/null +++ b/packages/squad-sdk/src/shared-squad.ts @@ -0,0 +1,655 @@ +/** + * 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 } 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; +} + +/** + * 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/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 From 3634b97f50ff268fc00a6c5cc24c332e8c29e093 Mon Sep 17 00:00:00 2001 From: Aaron Kubly Date: Tue, 21 Apr 2026 15:27:47 -0700 Subject: [PATCH 2/4] fix(templates): add shared squad resolution to coordinator instructions Adds the shared strategy (git-backed squad repos via ~/.squad/squad-repos.json pointer files) to the Worktree Awareness section. The coordinator now checks pointer files before falling back to platform app data, preventing sessions from failing to find shared squads after migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/agents/squad.agent.md | 41 ++++++++++++++----- .squad-templates/squad.agent.md | 41 ++++++++++++++----- .../templates/squad.agent.md.template | 41 ++++++++++++++----- .../templates/squad.agent.md.template | 41 ++++++++++++++----- templates/squad.agent.md.template | 41 ++++++++++++++----- 5 files changed, 155 insertions(+), 50 deletions(-) diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 01e18dfad..308b299aa 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -616,26 +616,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 +662,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..308b299aa 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -616,26 +616,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 +662,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-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index 01e18dfad..308b299aa 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -616,26 +616,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 +662,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/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index 01e18dfad..308b299aa 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -616,26 +616,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 +662,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..308b299aa 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -616,26 +616,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 +662,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. From 6a4ff1ef62c29a1ee4aedba999ed5ef9ffbda3a9 Mon Sep 17 00:00:00 2001 From: Aaron Kubly Date: Tue, 21 Apr 2026 17:49:55 -0700 Subject: [PATCH 3/4] fix(templates): inline shared resolution at top-level check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coordinator was short-circuiting after local .squad/ checks because the top-level decision said 'No → Init Mode' without requiring the full resolution chain. Now the 4-step chain (local → shared registry → app data → worktree) is inline at the decision point with a bold warning that ALL steps must be attempted before concluding Init Mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/agents/squad.agent.md | 16 ++++++++++++---- .squad-templates/squad.agent.md | 16 ++++++++++++---- .../squad-cli/templates/squad.agent.md.template | 16 ++++++++++++---- .../squad-sdk/templates/squad.agent.md.template | 16 ++++++++++++---- templates/squad.agent.md.template | 16 ++++++++++++---- 5 files changed, 60 insertions(+), 20 deletions(-) diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 308b299aa..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 --- diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index 308b299aa..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 --- diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index 308b299aa..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 --- diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index 308b299aa..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 --- diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index 308b299aa..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 --- From 0b2de521157913f332669c2e2eafb96483ba7d59 Mon Sep 17 00:00:00 2001 From: Aaron Kubly Date: Wed, 22 Apr 2026 15:16:31 -0700 Subject: [PATCH 4/4] feat(cli): add --squad-repo flag to init --shared Enables creating shared squads in git-backed squad repo clones instead of platform app data. Usage: squad init --shared --squad-repo D:\git\akubly.squad Creates team scaffolding at {squad-repo}/{key}/, registers in the clone's repos.json, and auto-adds the clone to ~/.squad/squad-repos.json for discovery. Also fixes init --shared to check git-backed pointers before concluding no existing squad exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 4 +- .../squad-cli/src/cli/commands/init-shared.ts | 36 ++++-- packages/squad-sdk/src/index.ts | 2 + packages/squad-sdk/src/shared-squad.ts | 107 +++++++++++++++++- 4 files changed, 137 insertions(+), 12 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 334dba6ba..ab913b401 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -306,8 +306,10 @@ async function main(): Promise { 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); + runInitShared(process.cwd(), key, squadRepo); return; } diff --git a/packages/squad-cli/src/cli/commands/init-shared.ts b/packages/squad-cli/src/cli/commands/init-shared.ts index 67818277b..6dd27ef3f 100644 --- a/packages/squad-cli/src/cli/commands/init-shared.ts +++ b/packages/squad-cli/src/cli/commands/init-shared.ts @@ -21,7 +21,9 @@ import { execSync } from 'node:child_process'; import { FSStorageProvider, createSharedSquad, + createSharedSquadInRepo, loadRepoRegistry, + lookupByKeyAcrossRepos, addUrlPattern, normalizeRemoteUrl, getRemoteUrl, @@ -81,7 +83,7 @@ function defaultDecisionsMd(): string { * @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): void { +export function runInitShared(cwd: string, keyArg?: string, squadRepoArg?: string): void { // Step 1: Determine repo key let key = keyArg; let urlPatterns: string[] = []; @@ -124,17 +126,21 @@ export function runInitShared(cwd: string, keyArg?: string): void { } // Step 3: Check if shared squad already exists — connect to it - const registry = loadRepoRegistry(); - const existing = registry?.repos.find(r => r.key === key); - if (existing) { - // Already registered — resolve teamDir and add URL pattern if new + // 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 (err) { - fatal((err as Error).message); + } catch { + globalDir = ''; } - const teamDir = path.join(globalDir, 'repos', ...key.split('/')); + 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]!)) { @@ -178,7 +184,7 @@ export function runInitShared(cwd: string, keyArg?: string): void { console.log(''); console.log(`✅ Connected to shared squad "${key}"`); console.log(` Team dir: ${teamDir}`); - console.log(` Resolution: via ${path.join(globalDir, 'repos.json')} (origin URL match)`); + 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}`); @@ -191,7 +197,13 @@ export function runInitShared(cwd: string, keyArg?: string): void { // Step 4: Create shared squad (writes manifest + registry) let teamDir: string; try { - teamDir = createSharedSquad(key, urlPatterns); + 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); } @@ -228,6 +240,10 @@ export function runInitShared(cwd: string, keyArg?: string): void { 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-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 42612501e..7d31407ce 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -26,6 +26,8 @@ export { loadRepoRegistry, saveRepoRegistry, createSharedSquad, + createSharedSquadInRepo, + addSquadRepoPointer, lookupByUrl, lookupByUrlAcrossRepos, lookupByKeyAcrossRepos, diff --git a/packages/squad-sdk/src/shared-squad.ts b/packages/squad-sdk/src/shared-squad.ts index e5d406ffa..43698c597 100644 --- a/packages/squad-sdk/src/shared-squad.ts +++ b/packages/squad-sdk/src/shared-squad.ts @@ -20,7 +20,7 @@ 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 } from './resolution-base.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'; @@ -509,6 +509,111 @@ export function createSharedSquad(repoKey: string, urlPatterns: string[]): strin 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. *