From 8bddcd65150cae879b7d72125b98040700a509f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Can=20G=C3=BCm=C3=BC=C5=9F?= Date: Sat, 8 Aug 2026 14:34:27 +0300 Subject: [PATCH] fix(cli): route watch/loop capability state through the effective state dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1490 squad watch and squad loop resolve externalized state for team.md/routing.md startup reads only, then hand every capability teamRoot and let them build .squad/ paths from it directly. After squad externalize the local .squad/ is a marker-only stub, so decision-hygiene, cleanup, retro, subsquad discovery, the capabilities/ loader, and the ralph-instructions.md check all silently saw an empty directory instead of the real state. Added stateRoot to WatchContext, populated from effectiveSquadDir().stateDir in both runWatch and runLoop (the compiler caught runLoop's missing field once stateRoot became required — same gap, different entry point). Every capability now reads/writes through context.stateRoot instead of joining .squad/ onto teamRoot. loadExternalCapabilities() and buildAgentPrompt() got an optional override parameter rather than a signature change, so their existing {teamRoot}/.squad/... default and every test built against it keep working unchanged. notes-promote.ts needed no change — it only reads the always-local config.json for backend-type detection; two-layer's actual state is git-notes/orphan-branch, not filesystem. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/fix-1490-watch-external-state.md | 7 ++ packages/squad-cli/src/cli/commands/loop.ts | 1 + .../commands/watch/capabilities/cleanup.ts | 5 +- .../watch/capabilities/decision-hygiene.ts | 4 +- .../commands/watch/capabilities/execute.ts | 13 +- .../cli/commands/watch/capabilities/retro.ts | 2 +- .../src/cli/commands/watch/external-loader.ts | 7 +- .../squad-cli/src/cli/commands/watch/index.ts | 19 ++- .../squad-cli/src/cli/commands/watch/types.ts | 7 ++ test/cli/watch-agent-spawn.test.ts | 1 + test/cli/watch-capabilities.test.ts | 118 ++++++++++++++++++ test/cli/watch-external-loader.test.ts | 79 ++++++++++++ test/watch-cleanup.test.ts | 1 + test/watch-notes-promote.test.ts | 1 + test/watch-subsquad-discovery.test.ts | 72 +++++++++++ 15 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 .changeset/fix-1490-watch-external-state.md create mode 100644 test/watch-subsquad-discovery.test.ts 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 ac98ccb9d..67350625a 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']); + }); +});