Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-1490-watch-external-state.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/squad-cli/src/cli/commands/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ export async function runLoop(dest: string, options: LoopConfig): Promise<void>

const baseContext: WatchContext = {
teamRoot,
stateRoot: stateDir,
adapter,
round: 0,
roster: roster.map(r => ({ name: r.name, label: r.label, expertise: [] as string[] })),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ export class CleanupCapability implements WatchCapability {
readonly phase = 'housekeeping' as const;

async preflight(context: WatchContext): Promise<PreflightResult> {
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 };
Expand All @@ -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[] = [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class DecisionHygieneCapability implements WatchCapability {
readonly phase = 'housekeeping' as const;

async preflight(context: WatchContext): Promise<PreflightResult> {
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' };
}
Expand All @@ -26,7 +26,7 @@ export class DecisionHygieneCapability implements WatchCapability {

async execute(context: WatchContext): Promise<CapabilityResult> {
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' };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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 = '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ const REQUIRED_FIELDS: ReadonlyArray<keyof WatchCapability> = [
];

/**
* 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.
Expand All @@ -32,8 +34,9 @@ const REQUIRED_FIELDS: ReadonlyArray<keyof WatchCapability> = [
export async function loadExternalCapabilities(
teamRoot: string,
registry: CapabilityRegistry,
capabilitiesDir?: string,
): Promise<number> {
const capDir = join(teamRoot, '.squad', 'capabilities');
const capDir = capabilitiesDir ?? join(teamRoot, '.squad', 'capabilities');

if (!existsSync(capDir)) {
return 0;
Expand Down
19 changes: 13 additions & 6 deletions packages/squad-cli/src/cli/commands/watch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? [];
Expand Down Expand Up @@ -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');
Expand All @@ -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[] })),
Expand Down Expand Up @@ -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}`);
}
Expand Down
7 changes: 7 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }>;
Expand Down
1 change: 1 addition & 0 deletions test/cli/watch-agent-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { buildCopilotCommand, spawnAgent } from '../../packages/squad-cli/src/cl
function makeContext(overrides: Partial<WatchContext> = {}): WatchContext {
return {
teamRoot: '/fake/team',
stateRoot: '/fake/team/.squad',
adapter: {} as WatchContext['adapter'],
round: 1,
roster: [],
Expand Down
118 changes: 118 additions & 0 deletions test/cli/watch-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -70,6 +71,7 @@ import { SelfPullCapability } from '../../packages/squad-cli/src/cli/commands/wa
function makeContext(overrides: Partial<WatchContext> = {}): WatchContext {
return {
teamRoot: '/fake/team',
stateRoot: '/fake/team/.squad',
adapter: {
listWorkItems: vi.fn().mockResolvedValue([]),
} as unknown as WatchContext['adapter'],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
});
});
});

// ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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');
});
});
});

// ────────────────────────────────────────────────────────────────
Expand Down
Loading