diff --git a/.changeset/fix-1490-watch-external-state.md b/.changeset/fix-1490-watch-external-state.md new file mode 100644 index 000000000..41ae40835 --- /dev/null +++ b/.changeset/fix-1490-watch-external-state.md @@ -0,0 +1,7 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Fix #1490: `squad watch` and `squad loop` resolved externalized state for their startup reads only — every capability was then handed `teamRoot` and built `.squad/` paths from it directly, so after `squad externalize` the local `.squad/` is a marker-only stub and decision-hygiene, cleanup, retro, subsquad discovery, the `capabilities/` loader, and the ralph-instructions.md check all silently saw an empty directory. + +Added `stateRoot` to `WatchContext`, populated from `effectiveSquadDir().stateDir` in both `runWatch` and `runLoop`, and routed every capability's state reads/writes through it instead of joining `.squad/` onto `teamRoot`. `loadExternalCapabilities()` and `buildAgentPrompt()` gained an optional override parameter rather than changing their existing (well-tested) `{teamRoot}/.squad/...` default, so every prior caller and test keeps working unchanged. `notes-promote.ts` needed no change — it only reads the always-local `config.json` for backend-type detection; the two-layer backend's actual state is git-notes/orphan-branch, not filesystem, so externalization doesn't touch it. diff --git a/packages/squad-cli/src/cli/commands/loop.ts b/packages/squad-cli/src/cli/commands/loop.ts index 21b8ac513..08a13f55f 100644 --- a/packages/squad-cli/src/cli/commands/loop.ts +++ b/packages/squad-cli/src/cli/commands/loop.ts @@ -350,6 +350,7 @@ export async function runLoop(dest: string, options: LoopConfig): Promise const baseContext: WatchContext = { teamRoot, + stateRoot: stateDir, adapter, round: 0, roster: roster.map(r => ({ name: r.name, label: r.label, expertise: [] as string[] })), diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts index 7dc647800..ade2ea8cb 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts @@ -74,8 +74,7 @@ export class CleanupCapability implements WatchCapability { readonly phase = 'housekeeping' as const; async preflight(context: WatchContext): Promise { - const squadDir = path.join(context.teamRoot, '.squad'); - if (!storage.existsSync(squadDir)) { + if (!storage.existsSync(context.stateRoot)) { return { ok: false, reason: '.squad/ directory not found' }; } return { ok: true }; @@ -90,7 +89,7 @@ export class CleanupCapability implements WatchCapability { return { success: true, summary: `cleanup: skipped (runs every ${everyN} rounds)` }; } - const squadDir = path.join(context.teamRoot, '.squad'); + const squadDir = context.stateRoot; const maxAge = config.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS; const actions: string[] = []; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts index ba732ece1..9dec04cc2 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.ts @@ -17,7 +17,7 @@ export class DecisionHygieneCapability implements WatchCapability { readonly phase = 'housekeeping' as const; async preflight(context: WatchContext): Promise { - const inboxDir = path.join(context.teamRoot, '.squad', 'decisions', 'inbox'); + const inboxDir = path.join(context.stateRoot, 'decisions', 'inbox'); if (!storage.existsSync(inboxDir)) { return { ok: false, reason: 'no decision inbox directory found' }; } @@ -26,7 +26,7 @@ export class DecisionHygieneCapability implements WatchCapability { async execute(context: WatchContext): Promise { try { - const inboxDir = path.join(context.teamRoot, '.squad', 'decisions', 'inbox'); + const inboxDir = path.join(context.stateRoot, 'decisions', 'inbox'); if (!storage.existsSync(inboxDir)) { return { success: true, summary: 'no decision inbox' }; } diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts index a3b0bdb5a..e21b2480f 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts @@ -90,13 +90,20 @@ function formatIssueList(issues: ExecutableWorkItem[]): string { }).join('\n'); } -/** Build the rich agent prompt matching PS1 ralph-watch design. */ +/** + * Build the rich agent prompt matching PS1 ralph-watch design. + * + * `stateDir` — where to look for ralph-instructions.md — defaults to + * `{teamRoot}/.squad` when omitted; pass the effective state dir directly + * to follow externalized state (#1490). + */ export function buildAgentPrompt( issues: ExecutableWorkItem[], teamRoot: string, + stateDir?: string, ): string { const issueList = formatIssueList(issues); - const hasInstructions = existsSync(path.join(teamRoot, '.squad', 'ralph-instructions.md')); + const hasInstructions = existsSync(path.join(stateDir ?? path.join(teamRoot, '.squad'), 'ralph-instructions.md')); if (hasInstructions) { return [ @@ -132,7 +139,7 @@ async function executeAll( context: WatchContext, timeoutMs: number, ): Promise<{ success: boolean; error?: string }> { - const prompt = buildAgentPrompt(issues, context.teamRoot); + const prompt = buildAgentPrompt(issues, context.teamRoot, context.stateRoot); // Load Ralph's charter to give the spawned session full specialist context. let charterPrefix = ''; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts index 72e04a463..0213c4b0d 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/retro.ts @@ -26,7 +26,7 @@ export class RetroCapability implements WatchCapability { const isFriday = now.getUTCDay() === 5; const isAfternoon = now.getUTCHours() >= 14; - const logDir = path.join(context.teamRoot, '.squad', 'log'); + const logDir = path.join(context.stateRoot, 'log'); let lastRetroAge = Infinity; try { const files = storage.listSync?.(logDir) ?? []; diff --git a/packages/squad-cli/src/cli/commands/watch/external-loader.ts b/packages/squad-cli/src/cli/commands/watch/external-loader.ts index 8f1fb63fd..3ea288e52 100644 --- a/packages/squad-cli/src/cli/commands/watch/external-loader.ts +++ b/packages/squad-cli/src/cli/commands/watch/external-loader.ts @@ -23,7 +23,9 @@ const REQUIRED_FIELDS: ReadonlyArray = [ ]; /** - * Load external WatchCapability modules from `{teamRoot}/.squad/capabilities/`. + * Load external WatchCapability modules from `{teamRoot}/.squad/capabilities/`, + * or from `capabilitiesDir` directly when given (the external state dir's + * `capabilities/` when `squad externalize` is active — #1490). * * - Skips silently when the directory does not exist. * - Logs a warning and continues when a file fails to load. @@ -32,8 +34,9 @@ const REQUIRED_FIELDS: ReadonlyArray = [ export async function loadExternalCapabilities( teamRoot: string, registry: CapabilityRegistry, + capabilitiesDir?: string, ): Promise { - const capDir = join(teamRoot, '.squad', 'capabilities'); + const capDir = capabilitiesDir ?? join(teamRoot, '.squad', 'capabilities'); if (!existsSync(capDir)) { return 0; diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index 6204fa0b6..90355b7a1 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -395,10 +395,15 @@ async function runCheck( // ── SubSquad Discovery ─────────────────────────────────────────── -interface SubSquad { name: string; dir: string; labels: string[] } +export interface SubSquad { name: string; dir: string; labels: string[] } -function discoverSubSquads(teamRoot: string): SubSquad[] { - const subsquadDir = path.join(teamRoot, '.squad', 'subsquads'); +/** + * Discover subsquads under `{stateRoot}/subsquads/`. `subsquads/` is not in + * externalize's KEEP_LOCAL set, so after `squad externalize` it lives at the + * external state dir — pass the effective state dir here, not teamRoot (#1490). + */ +export function discoverSubSquads(stateRoot: string): SubSquad[] { + const subsquadDir = path.join(stateRoot, 'subsquads'); if (!storage.existsSync(subsquadDir)) return []; try { const entries = storage.listSync?.(subsquadDir) ?? []; @@ -779,9 +784,10 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig // ── Capability system setup ──────────────────────────────────── const registry = createDefaultRegistry(); - // Load external capabilities from .squad/capabilities/ + // Load external capabilities — from the external state dir's + // capabilities/ when externalized, otherwise .squad/capabilities/ (#1490) const { loadExternalCapabilities } = await import('./external-loader.js'); - await loadExternalCapabilities(teamRoot, registry); + await loadExternalCapabilities(teamRoot, registry, path.join(stateDir, 'capabilities')); // PID tracking for child process cleanup const { PidTracker } = await import('./pid-tracker.js'); @@ -798,6 +804,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig const baseContext: WatchContext = { teamRoot, + stateRoot: stateDir, adapter, round: 0, roster: roster.map(r => ({ name: r.name, label: r.label, expertise: [] as string[] })), @@ -908,7 +915,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig await runPhase('pre-scan', enabledCapabilities, roundContext, config); // SubSquad discovery (informational, not a capability) - const subSquads = discoverSubSquads(teamRoot); + const subSquads = discoverSubSquads(stateDir); if (subSquads.length > 0 && round === 1) { console.log(`${DIM}📂 Discovered ${subSquads.length} subsquad(s): ${subSquads.map(s => s.name).join(', ')}${RESET}`); } diff --git a/packages/squad-cli/src/cli/commands/watch/types.ts b/packages/squad-cli/src/cli/commands/watch/types.ts index 05ee68411..781dc04ca 100644 --- a/packages/squad-cli/src/cli/commands/watch/types.ts +++ b/packages/squad-cli/src/cli/commands/watch/types.ts @@ -29,6 +29,13 @@ export interface CapabilityResult { /** Runtime context passed to every capability during each round. */ export interface WatchContext { teamRoot: string; + /** + * Effective state directory — the local `.squad/` dir, or the external + * state dir when `squad externalize` is active (#1490). Capabilities + * must read/write squad state (decisions, log, ralph-instructions.md, + * etc.) through this, not by joining `.squad/` onto `teamRoot`. + */ + stateRoot: string; adapter: PlatformAdapter; round: number; roster: Array<{ name: string; label: string; expertise: string[] }>; diff --git a/test/cli/watch-agent-spawn.test.ts b/test/cli/watch-agent-spawn.test.ts index 1174f3181..d77b09ba5 100644 --- a/test/cli/watch-agent-spawn.test.ts +++ b/test/cli/watch-agent-spawn.test.ts @@ -34,6 +34,7 @@ import { buildCopilotCommand, spawnAgent } from '../../packages/squad-cli/src/cl function makeContext(overrides: Partial = {}): WatchContext { return { teamRoot: '/fake/team', + stateRoot: '/fake/team/.squad', adapter: {} as WatchContext['adapter'], round: 1, roster: [], diff --git a/test/cli/watch-capabilities.test.ts b/test/cli/watch-capabilities.test.ts index 08e6685c2..d0a68e9f0 100644 --- a/test/cli/watch-capabilities.test.ts +++ b/test/cli/watch-capabilities.test.ts @@ -62,6 +62,7 @@ import { import type { ExecutableWorkItem } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/execute.js'; import { CleanupCapability } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.js'; import { DecisionHygieneCapability } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/decision-hygiene.js'; +import { RetroCapability } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/retro.js'; import { BoardCapability } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/board.js'; import { SelfPullCapability } from '../../packages/squad-cli/src/cli/commands/watch/capabilities/self-pull.js'; @@ -70,6 +71,7 @@ import { SelfPullCapability } from '../../packages/squad-cli/src/cli/commands/wa function makeContext(overrides: Partial = {}): WatchContext { return { teamRoot: '/fake/team', + stateRoot: '/fake/team/.squad', adapter: { listWorkItems: vi.fn().mockResolvedValue([]), } as unknown as WatchContext['adapter'], @@ -214,6 +216,25 @@ describe('Watch Capabilities', () => { expect(prompt).toContain('Ralph, Go!'); }); + // Regression (#1490): a 2-arg call still derives the check from + // teamRoot + '.squad' (unchanged, backward compatible), but the + // real caller (ExecuteCapability.executeAll) now passes + // context.stateRoot as the 3rd arg, which must win when given — + // that's what lets ralph-instructions.md be found after externalize. + it('checks ralph-instructions.md under the passed stateDir when given, not teamRoot/.squad', () => { + mockFsExistsSync.mockImplementation((p: unknown) => { + return typeof p === 'string' && p.endsWith('ralph-instructions.md'); + }); + const issues: ExecutableWorkItem[] = [ + { number: 1, title: 'Task', labels: [{ name: 'squad' }], assignees: [] }, + ]; + const prompt = buildAgentPrompt(issues, '/local/repo', '/external/state'); + const [calledPath] = mockFsExistsSync.mock.calls[0] as [string]; + expect(calledPath.replace(/\\/g, '/')).toBe('/external/state/ralph-instructions.md'); + expect(calledPath.replace(/\\/g, '/')).not.toContain('/local/repo'); + expect(prompt).toContain('Ralph, Go!'); + }); + it('formats labels and assignees in issue list', () => { const issues: ExecutableWorkItem[] = [{ number: 42, @@ -523,6 +544,40 @@ describe('Watch Capabilities', () => { expect(result.summary).toContain('nothing to do'); }); }); + + // Regression (#1490): before the fix, every path here was built from + // teamRoot + '.squad', so after `squad externalize` the local .squad/ + // is a marker-only stub and every check silently sees an empty + // directory. Give teamRoot and stateRoot different fake roots and + // confirm the capability only ever looks under stateRoot. + describe('externalized state (#1490)', () => { + it('preflight checks stateRoot, not teamRoot/.squad', async () => { + mockStorage.existsSync.mockImplementation((dir: string) => dir === '/external/state'); + const cap = new CleanupCapability(); + const result = await cap.preflight( + makeContext({ teamRoot: '/local/repo', stateRoot: '/external/state' }), + ); + expect(result.ok).toBe(true); + expect(mockStorage.existsSync).toHaveBeenCalledWith('/external/state'); + }); + + it('execute lists scratch/orchestration-log/log/inbox under stateRoot, not teamRoot', async () => { + const seenDirs: string[] = []; + mockStorage.listSync.mockImplementation((dir: string) => { + seenDirs.push(dir); + return []; + }); + const cap = new CleanupCapability(); + await cap.execute(makeContext({ teamRoot: '/local/repo', stateRoot: '/external/state', round: 1 })); + + expect(seenDirs.length).toBeGreaterThan(0); + for (const dir of seenDirs) { + const normalized = dir.replace(/\\/g, '/'); + expect(normalized.startsWith('/external/state')).toBe(true); + expect(normalized.includes('/local/repo')).toBe(false); + } + }); + }); }); // ──────────────────────────────────────────────────────────────── @@ -606,6 +661,69 @@ describe('Watch Capabilities', () => { expect(result.summary).toContain('no decision inbox'); }); }); + + // Regression (#1490): preflight/execute both built the inbox path from + // teamRoot + '.squad' — after externalize the inbox lives at stateRoot + // instead, and the old code silently never found it. + describe('externalized state (#1490)', () => { + it('preflight/execute both check the inbox under stateRoot, not teamRoot', async () => { + mockStorage.existsSync.mockImplementation((dir: string) => dir.replace(/\\/g, '/').startsWith('/external/state')); + const seenDirs: string[] = []; + mockStorage.listSync.mockImplementation((dir: string) => { + seenDirs.push(dir); + return ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md']; + }); + const cap = new DecisionHygieneCapability(); + const ctx = makeContext({ teamRoot: '/local/repo', stateRoot: '/external/state' }); + + const preflight = await cap.preflight(ctx); + expect(preflight.ok).toBe(true); + + const result = await cap.execute(ctx); + expect(result.success).toBe(true); + expect(result.summary).toContain('merged'); + expect(seenDirs.length).toBeGreaterThan(0); + for (const dir of seenDirs) { + expect(dir.replace(/\\/g, '/').startsWith('/external/state')).toBe(true); + } + }); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // RetroCapability — retrospective staleness check + write + // ──────────────────────────────────────────────────────────────── + + describe('RetroCapability', () => { + describe('execute', () => { + it('reports not due when a recent retrospective log exists', async () => { + const today = new Date().toISOString().slice(0, 10); + mockStorage.listSync.mockReturnValue([`${today}-retrospective.md`]); + const cap = new RetroCapability(); + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toBe('retro not due'); + }); + + // Regression (#1490): the staleness check listed teamRoot + '.squad/log' + // — after externalize that's the local marker dir (empty), so the + // capability always saw "no retro found" and fired on every round + // regardless of an existing, externalized retrospective log. + it('checks log/ under stateRoot, not teamRoot, for existing retrospectives', async () => { + const today = new Date().toISOString().slice(0, 10); + const seenDirs: string[] = []; + mockStorage.listSync.mockImplementation((dir: string) => { + seenDirs.push(dir); + return dir.replace(/\\/g, '/') === '/external/state/log' ? [`${today}-retrospective.md`] : []; + }); + const cap = new RetroCapability(); + const result = await cap.execute( + makeContext({ teamRoot: '/local/repo', stateRoot: '/external/state' }), + ); + expect(seenDirs.map(d => d.replace(/\\/g, '/'))).toContain('/external/state/log'); + expect(result.summary).toBe('retro not due'); + }); + }); }); // ──────────────────────────────────────────────────────────────── diff --git a/test/cli/watch-external-loader.test.ts b/test/cli/watch-external-loader.test.ts index b6cc8d655..b1a79d7c1 100644 --- a/test/cli/watch-external-loader.test.ts +++ b/test/cli/watch-external-loader.test.ts @@ -238,3 +238,82 @@ describe('loadExternalCapabilities', () => { expect(warnLog).toBeDefined(); }); }); + +// Regression (#1490): the loader always scanned {teamRoot}/.squad/capabilities/, +// so after `squad externalize` moves capabilities/ out of the local .squad/, +// custom capabilities silently stopped loading. The optional capabilitiesDir +// override lets the caller point at the resolved state dir instead — kept +// as an override rather than changing the default so every existing test +// above (all of which rely on the {teamRoot}/.squad/capabilities/ default) +// keeps working unchanged. +describe('loadExternalCapabilities — capabilitiesDir override (#1490)', () => { + let tmpDir: string; + let externalDir: string; + let registry: CapabilityRegistry; + let consoleSpy: ReturnType; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'squad-ext-cap-team-')); + externalDir = await mkdtemp(join(tmpdir(), 'squad-ext-cap-external-')); + registry = new CapabilityRegistry(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + consoleSpy.mockRestore(); + for (const d of [tmpDir, externalDir]) { + if (existsSync(d)) await rm(d, { recursive: true, force: true }); + } + }); + + const capCode = ` + export default { + name: 'external-cap', + description: 'A capability living in the externalized state dir', + configShape: 'boolean', + requires: [], + phase: 'housekeeping', + async preflight() { return { ok: true }; }, + async execute() { return { success: true, summary: 'done' }; }, + }; + `; + + it('loads from capabilitiesDir when given, ignoring {teamRoot}/.squad/capabilities/', async () => { + // A decoy under the local .squad/ — must NOT be loaded, since a real + // externalized project's local .squad/ holds only the marker config.json. + const localCapDir = join(tmpDir, '.squad', 'capabilities'); + mkdirSync(localCapDir, { recursive: true }); + await writeFile(join(localCapDir, 'decoy.js'), capCode.replace('external-cap', 'decoy-cap'), 'utf-8'); + + const externalCapDir = join(externalDir, 'capabilities'); + mkdirSync(externalCapDir, { recursive: true }); + await writeFile(join(externalCapDir, 'external-cap.js'), capCode, 'utf-8'); + + const loadExternalCapabilities = await getLoader(); + const count = await loadExternalCapabilities(tmpDir, registry, externalCapDir); + + expect(count).toBe(1); + expect(registry.names()).toContain('external-cap'); + expect(registry.names()).not.toContain('decoy-cap'); + }); + + it('falls back to {teamRoot}/.squad/capabilities/ when capabilitiesDir is omitted', async () => { + const capDir = join(tmpDir, '.squad', 'capabilities'); + mkdirSync(capDir, { recursive: true }); + await writeFile(join(capDir, 'external-cap.js'), capCode, 'utf-8'); + + const loadExternalCapabilities = await getLoader(); + const count = await loadExternalCapabilities(tmpDir, registry); + + expect(count).toBe(1); + expect(registry.names()).toContain('external-cap'); + }); + + it('returns 0 when capabilitiesDir is given but does not exist', async () => { + const loadExternalCapabilities = await getLoader(); + const count = await loadExternalCapabilities(tmpDir, registry, join(externalDir, 'capabilities')); + + expect(count).toBe(0); + expect(registry.all()).toHaveLength(0); + }); +}); diff --git a/test/watch-cleanup.test.ts b/test/watch-cleanup.test.ts index 7cc2b6407..4dafb84d2 100644 --- a/test/watch-cleanup.test.ts +++ b/test/watch-cleanup.test.ts @@ -14,6 +14,7 @@ import type { WatchContext } from '../packages/squad-cli/src/cli/commands/watch/ function makeContext(overrides: Partial = {}): WatchContext { return { teamRoot: TEST_ROOT, + stateRoot: SQUAD_DIR, adapter: {} as WatchContext['adapter'], round: 1, roster: [], diff --git a/test/watch-notes-promote.test.ts b/test/watch-notes-promote.test.ts index b392bcd91..80cbea84c 100644 --- a/test/watch-notes-promote.test.ts +++ b/test/watch-notes-promote.test.ts @@ -34,6 +34,7 @@ function mkRepo(backend: string): string { function makeContext(teamRoot: string, round = 1, config: Record = {}): WatchContext { return { teamRoot, + stateRoot: path.join(teamRoot, '.squad'), adapter: {} as WatchContext['adapter'], round, roster: [], diff --git a/test/watch-subsquad-discovery.test.ts b/test/watch-subsquad-discovery.test.ts new file mode 100644 index 000000000..e0290abc5 --- /dev/null +++ b/test/watch-subsquad-discovery.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const TEAM_ROOT = path.join(os.tmpdir(), `squad-subsquad-team-${Date.now()}`); +const EXTERNAL_ROOT = path.join(os.tmpdir(), `squad-subsquad-external-${Date.now()}`); + +import { discoverSubSquads } from '../packages/squad-cli/src/cli/commands/watch/index.js'; + +function writeSquad(root: string, relativePath: string, content: string = ''): void { + const full = path.join(root, relativePath); + mkdirSync(path.dirname(full), { recursive: true }); + writeFileSync(full, content); +} + +beforeEach(() => { + mkdirSync(TEAM_ROOT, { recursive: true }); + mkdirSync(EXTERNAL_ROOT, { recursive: true }); +}); + +afterEach(() => { + rmSync(TEAM_ROOT, { recursive: true, force: true }); + rmSync(EXTERNAL_ROOT, { recursive: true, force: true }); +}); + +describe('discoverSubSquads', () => { + it('finds subsquads with a team.md under {stateRoot}/subsquads/', () => { + writeSquad(TEAM_ROOT, '.squad/subsquads/frontend/team.md', '# Frontend Team\n'); + writeSquad(TEAM_ROOT, '.squad/subsquads/backend/team.md', '# Backend Team\n'); + + const squads = discoverSubSquads(path.join(TEAM_ROOT, '.squad')); + expect(squads.map(s => s.name).sort()).toEqual(['backend', 'frontend']); + }); + + it('skips entries without a team.md', () => { + writeSquad(TEAM_ROOT, '.squad/subsquads/frontend/team.md', '# Frontend Team\n'); + mkdirSync(path.join(TEAM_ROOT, '.squad', 'subsquads', 'empty-dir'), { recursive: true }); + + const squads = discoverSubSquads(path.join(TEAM_ROOT, '.squad')); + expect(squads.map(s => s.name)).toEqual(['frontend']); + }); + + it('parses labels from routing.md when present', () => { + writeSquad(TEAM_ROOT, '.squad/subsquads/frontend/team.md', '# Frontend Team\n'); + writeSquad(TEAM_ROOT, '.squad/subsquads/frontend/routing.md', 'labels: squad:frontend, ui\n'); + + const squads = discoverSubSquads(path.join(TEAM_ROOT, '.squad')); + expect(squads[0]!.labels).toEqual(['squad:frontend', 'ui']); + }); + + it('returns empty when the subsquads directory does not exist', () => { + const squads = discoverSubSquads(path.join(TEAM_ROOT, '.squad')); + expect(squads).toEqual([]); + }); + + // Regression (#1490): before the fix, runWatch called + // discoverSubSquads(teamRoot), which joined '.squad/subsquads' itself — + // after `squad externalize`, subsquads/ lives at the external state dir, + // not under the local .squad/, so discovery silently found nothing. + // Proven here directly: the same subsquad only shows up when discovery + // is pointed at the dir that actually holds it. + it('finds nothing under the local .squad/ once subsquads/ has moved to an external state dir', () => { + writeSquad(EXTERNAL_ROOT, 'subsquads/frontend/team.md', '# Frontend Team\n'); + + const local = discoverSubSquads(path.join(TEAM_ROOT, '.squad')); + expect(local).toEqual([]); + + const external = discoverSubSquads(EXTERNAL_ROOT); + expect(external.map(s => s.name)).toEqual(['frontend']); + }); +});