From 7da7d7fc4aab973850f4bf915ec541c927a93b8c Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:27:27 -0700 Subject: [PATCH 1/3] test: personal squad init discovery tests (#576) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/personal-squad-init.test.ts | 557 +++++++++++++++++++++++++++++++ 1 file changed, 557 insertions(+) create mode 100644 test/personal-squad-init.test.ts diff --git a/test/personal-squad-init.test.ts b/test/personal-squad-init.test.ts new file mode 100644 index 000000000..3a2c4ad18 --- /dev/null +++ b/test/personal-squad-init.test.ts @@ -0,0 +1,557 @@ +/** + * Personal Squad Init & Discovery Tests + * + * Tests for Issue #576 — personal squad discovery during `init --global` + * (via npx) and subsequent repo init flows. + * + * Covers: + * - resolveGlobalSquadPath() platform-specific path resolution + * - resolvePersonalSquadDir() discovery and kill-switch + * - personalInit (CLI) creates correct directory structure + * - Repo-level resolveSquadPaths() includes personalDir + * - Edge cases: empty dir, partial state, env overrides, npx execution + */ + +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 { randomBytes } from 'crypto'; +import { + resolveGlobalSquadPath, + resolvePersonalSquadDir, + resolveSquadPaths, + ensureSquadPathTriple, +} from '@bradygaster/squad-sdk/resolution'; +import { + resolvePersonalAgents, + mergeSessionCast, + type PersonalAgentManifest, +} from '@bradygaster/squad-sdk/agents/personal'; + +const TEST_ROOT = join( + process.cwd(), + `.test-personal-init-${randomBytes(4).toString('hex')}`, +); + +function cleanup() { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// 1. resolveGlobalSquadPath — platform-specific paths +// --------------------------------------------------------------------------- +describe('resolveGlobalSquadPath — platform paths', () => { + beforeEach(() => { cleanup(); mkdirSync(TEST_ROOT, { recursive: true }); }); + afterEach(() => { cleanup(); vi.unstubAllEnvs(); }); + + it('returns a path ending with "squad"', () => { + const p = resolveGlobalSquadPath(); + expect(p.endsWith('squad')).toBe(true); + }); + + it('creates the directory if it does not exist', () => { + const p = resolveGlobalSquadPath(); + expect(existsSync(p)).toBe(true); + }); + + it('Windows: prefers APPDATA over LOCALAPPDATA and homedir fallback', () => { + if (process.platform !== 'win32') return; // Windows-only assertion + const p = resolveGlobalSquadPath(); + const appdata = process.env['APPDATA']; + if (appdata) { + expect(p).toBe(join(appdata, 'squad')); + } + }); + + it('Linux: respects XDG_CONFIG_HOME when set', () => { + if (process.platform !== 'linux') return; // Linux-only assertion + const custom = join(TEST_ROOT, 'xdg-home'); + mkdirSync(custom, { recursive: true }); + const saved = process.env['XDG_CONFIG_HOME']; + try { + process.env['XDG_CONFIG_HOME'] = custom; + const p = resolveGlobalSquadPath(); + expect(p).toBe(join(custom, 'squad')); + expect(existsSync(p)).toBe(true); + } finally { + if (saved !== undefined) process.env['XDG_CONFIG_HOME'] = saved; + else delete process.env['XDG_CONFIG_HOME']; + } + }); + + it('returns a consistent path across repeated calls', () => { + const a = resolveGlobalSquadPath(); + const b = resolveGlobalSquadPath(); + expect(a).toBe(b); + }); +}); + +// --------------------------------------------------------------------------- +// 2. resolvePersonalSquadDir — npx / install-agnostic discovery +// --------------------------------------------------------------------------- +describe('resolvePersonalSquadDir — discovery & kill-switch', () => { + let savedNoPersonal: string | undefined; + + beforeEach(() => { + cleanup(); + mkdirSync(TEST_ROOT, { recursive: true }); + savedNoPersonal = process.env['SQUAD_NO_PERSONAL']; + delete process.env['SQUAD_NO_PERSONAL']; + }); + + afterEach(() => { + if (savedNoPersonal !== undefined) process.env['SQUAD_NO_PERSONAL'] = savedNoPersonal; + else delete process.env['SQUAD_NO_PERSONAL']; + cleanup(); + vi.unstubAllEnvs(); + }); + + it('returns null when SQUAD_NO_PERSONAL=1', () => { + process.env['SQUAD_NO_PERSONAL'] = '1'; + expect(resolvePersonalSquadDir()).toBeNull(); + }); + + it('returns null for any truthy SQUAD_NO_PERSONAL value', () => { + for (const val of ['true', 'yes', 'on', 'anything']) { + process.env['SQUAD_NO_PERSONAL'] = val; + expect(resolvePersonalSquadDir()).toBeNull(); + } + }); + + it('returns null when personal-squad subdir does not exist', () => { + // Ensure the global dir exists but personal-squad does not + const globalDir = resolveGlobalSquadPath(); + const personalDir = join(globalDir, 'personal-squad'); + if (existsSync(personalDir)) { + // If it already exists on this machine, skip this assertion + return; + } + expect(resolvePersonalSquadDir()).toBeNull(); + }); + + it('returns path when personal-squad directory exists', () => { + const globalDir = resolveGlobalSquadPath(); + const personalDir = join(globalDir, 'personal-squad'); + mkdirSync(personalDir, { recursive: true }); + try { + const result = resolvePersonalSquadDir(); + expect(result).toBe(personalDir); + } finally { + rmSync(personalDir, { recursive: true, force: true }); + } + }); + + it('works regardless of how the CLI was invoked (npx, global, local)', () => { + // resolvePersonalSquadDir uses os.homedir / env vars — not process.argv[0] + // Verify it does NOT inspect process.argv or require a specific install path + const globalDir = resolveGlobalSquadPath(); + const personalDir = join(globalDir, 'personal-squad'); + mkdirSync(personalDir, { recursive: true }); + try { + // Simulate npx-style execution context by verifying the function + // ignores argv entirely and resolves from env/homedir + const saved = process.argv[1]; + process.argv[1] = '/fake/.npm/_npx/squad-cli/node_modules/.bin/squad'; + const result = resolvePersonalSquadDir(); + process.argv[1] = saved; + expect(result).toBe(personalDir); + } finally { + rmSync(personalDir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// 3. personalInit — creates files at correct location +// --------------------------------------------------------------------------- +describe('personal init — directory structure', () => { + beforeEach(() => { cleanup(); mkdirSync(TEST_ROOT, { recursive: true }); }); + afterEach(() => { cleanup(); }); + + it('creates personal-squad/agents/ and config.json', async () => { + // Simulate what personalInit() does (we can't call the private fn directly, + // so we replicate its logic and validate the contract). + const globalDir = join(TEST_ROOT, 'global'); + const personalDir = join(globalDir, 'personal-squad'); + const agentsDir = join(personalDir, 'agents'); + const configPath = join(personalDir, 'config.json'); + + mkdirSync(agentsDir, { recursive: true }); + const config = { defaultModel: 'auto', ghostProtocol: true }; + await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + + expect(existsSync(personalDir)).toBe(true); + expect(existsSync(agentsDir)).toBe(true); + expect(existsSync(configPath)).toBe(true); + + const parsed = JSON.parse(await readFile(configPath, 'utf-8')); + expect(parsed).toEqual({ defaultModel: 'auto', ghostProtocol: true }); + }); + + it('config.json always enables ghostProtocol', async () => { + const personalDir = join(TEST_ROOT, 'ps'); + mkdirSync(personalDir, { recursive: true }); + const configPath = join(personalDir, 'config.json'); + const config = { defaultModel: 'auto', ghostProtocol: true }; + await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + + const parsed = JSON.parse(await readFile(configPath, 'utf-8')); + expect(parsed.ghostProtocol).toBe(true); + }); + + it('does NOT overwrite when personal-squad already exists', () => { + // personalInit exits early if dir exists — verify idempotency + const personalDir = join(TEST_ROOT, 'existing-ps'); + mkdirSync(personalDir, { recursive: true }); + + // The fact that the dir exists means init would warn and return early + expect(existsSync(personalDir)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Repo init discovers existing personal squad via resolveSquadPaths +// --------------------------------------------------------------------------- +describe('resolveSquadPaths — includes personalDir', () => { + const repoRoot = join(TEST_ROOT, 'my-repo'); + const squadDir = join(repoRoot, '.squad'); + + beforeEach(() => { + cleanup(); + mkdirSync(squadDir, { recursive: true }); + }); + afterEach(() => { cleanup(); vi.unstubAllEnvs(); }); + + it('resolveSquadPaths returns personalDir from resolvePersonalSquadDir', () => { + const paths = resolveSquadPaths(repoRoot); + if (!paths) { + // No .squad found — that's acceptable in this synthetic root, + // but the directory was created above so it should resolve. + return; + } + // personalDir should be either a string or null depending on machine state + expect(paths).toHaveProperty('personalDir'); + expect( + paths.personalDir === null || typeof paths.personalDir === 'string', + ).toBe(true); + }); + + it('personalDir is null when SQUAD_NO_PERSONAL is set', () => { + process.env['SQUAD_NO_PERSONAL'] = '1'; + const paths = resolveSquadPaths(repoRoot); + if (!paths) return; + expect(paths.personalDir).toBeNull(); + delete process.env['SQUAD_NO_PERSONAL']; + }); + + it('repo init works correctly when NO personal squad exists', () => { + process.env['SQUAD_NO_PERSONAL'] = '1'; + const paths = resolveSquadPaths(repoRoot); + delete process.env['SQUAD_NO_PERSONAL']; + if (!paths) return; + // All other fields should still be valid + expect(paths.projectDir).toBe(squadDir); + expect(paths.teamDir).toBeTruthy(); + expect(paths.mode).toBe('local'); + expect(paths.personalDir).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Edge: personal squad dir exists but is empty (no agents/, no config) +// --------------------------------------------------------------------------- +describe('edge — empty personal squad directory', () => { + beforeEach(() => { cleanup(); mkdirSync(TEST_ROOT, { recursive: true }); }); + afterEach(() => { cleanup(); }); + + it('resolvePersonalSquadDir returns path even when dir is empty', () => { + const globalDir = resolveGlobalSquadPath(); + const personalDir = join(globalDir, 'personal-squad'); + mkdirSync(personalDir, { recursive: true }); + try { + // The function only checks fs.existsSync(personalDir), not contents + expect(resolvePersonalSquadDir()).toBe(personalDir); + } finally { + rmSync(personalDir, { recursive: true, force: true }); + } + }); + + it('resolvePersonalAgents returns [] when agents subdir is missing', async () => { + const globalDir = resolveGlobalSquadPath(); + const personalDir = join(globalDir, 'personal-squad'); + mkdirSync(personalDir, { recursive: true }); + try { + const agents = await resolvePersonalAgents(); + expect(agents).toEqual([]); + } finally { + rmSync(personalDir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// 6. Edge: partial state — agents/ exists but no charter.md files +// --------------------------------------------------------------------------- +describe('edge — partial personal squad state', () => { + let personalDir: string; + + beforeEach(() => { + cleanup(); + mkdirSync(TEST_ROOT, { recursive: true }); + const globalDir = resolveGlobalSquadPath(); + personalDir = join(globalDir, 'personal-squad'); + }); + afterEach(() => { + if (existsSync(personalDir)) rmSync(personalDir, { recursive: true, force: true }); + cleanup(); + }); + + it('agents dir exists but is empty → returns []', async () => { + const agentsDir = join(personalDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + const agents = await resolvePersonalAgents(); + expect(agents).toEqual([]); + }); + + it('agent subdir exists without charter.md → skipped', async () => { + const agentsDir = join(personalDir, 'agents'); + const broken = join(agentsDir, 'orphan-agent'); + mkdirSync(broken, { recursive: true }); + await writeFile(join(broken, 'history.md'), '# History\n', 'utf-8'); + + const agents = await resolvePersonalAgents(); + expect(agents).toEqual([]); + }); + + it('agent subdir with charter.md but missing Role → defaults to "personal"', async () => { + const agentsDir = join(personalDir, 'agents'); + const agentDir = join(agentsDir, 'minimalist'); + mkdirSync(agentDir, { recursive: true }); + await writeFile( + join(agentDir, 'charter.md'), + '# Minimalist\n\nNo metadata here.\n', + 'utf-8', + ); + + const agents = await resolvePersonalAgents(); + expect(agents.length).toBe(1); + expect(agents[0].name).toBe('minimalist'); + expect(agents[0].role).toBe('personal'); // default when no Role line + expect(agents[0].personal.ghostProtocol).toBe(true); + }); + + it('mix of valid and invalid agent dirs → only valid agents returned', async () => { + const agentsDir = join(personalDir, 'agents'); + + // Valid agent + const goodDir = join(agentsDir, 'good-agent'); + mkdirSync(goodDir, { recursive: true }); + await writeFile( + join(goodDir, 'charter.md'), + '# Good\n\n**Name:** Good Agent\n**Role:** Developer\n', + 'utf-8', + ); + + // No charter + const noCharterDir = join(agentsDir, 'no-charter'); + mkdirSync(noCharterDir, { recursive: true }); + await writeFile(join(noCharterDir, 'notes.txt'), 'just notes', 'utf-8'); + + // A file (not directory) — should be skipped + await writeFile(join(agentsDir, 'stray-file.txt'), 'not a dir', 'utf-8'); + + const agents = await resolvePersonalAgents(); + expect(agents.length).toBe(1); + expect(agents[0].name).toBe('good-agent'); + expect(agents[0].role).toBe('Developer'); + expect(agents[0].personal.origin).toBe('personal'); + expect(agents[0].personal.sourceDir).toBe(goodDir); + }); +}); + +// --------------------------------------------------------------------------- +// 7. SQUAD_NO_PERSONAL kill-switch across the full stack +// --------------------------------------------------------------------------- +describe('SQUAD_NO_PERSONAL kill-switch', () => { + let saved: string | undefined; + + beforeEach(() => { + saved = process.env['SQUAD_NO_PERSONAL']; + }); + afterEach(() => { + if (saved !== undefined) process.env['SQUAD_NO_PERSONAL'] = saved; + else delete process.env['SQUAD_NO_PERSONAL']; + }); + + it('resolvePersonalSquadDir returns null', () => { + process.env['SQUAD_NO_PERSONAL'] = '1'; + expect(resolvePersonalSquadDir()).toBeNull(); + }); + + it('resolvePersonalAgents returns empty array', async () => { + process.env['SQUAD_NO_PERSONAL'] = '1'; + expect(await resolvePersonalAgents()).toEqual([]); + }); + + it('empty-string value does NOT disable (truthy check)', () => { + process.env['SQUAD_NO_PERSONAL'] = ''; + // An empty string is falsy in JS, so the kill switch should NOT trigger + // The function returns null only if the env var is truthy + // However, the implementation is: if (process.env['SQUAD_NO_PERSONAL']) return null; + // Empty string is falsy, so personal squad is NOT disabled + const result = resolvePersonalSquadDir(); + // result could be null if dir doesn't exist, but the kill switch didn't fire + // We verify the kill switch didn't fire by checking the global dir path + const globalDir = resolveGlobalSquadPath(); + const personalDir = join(globalDir, 'personal-squad'); + if (existsSync(personalDir)) { + expect(result).toBe(personalDir); + } else { + expect(result).toBeNull(); // null because dir missing, not kill switch + } + }); +}); + +// --------------------------------------------------------------------------- +// 8. mergeSessionCast — personal agents respect project precedence +// --------------------------------------------------------------------------- +describe('mergeSessionCast — init-time discovery', () => { + it('empty personal agents → returns only project agents', () => { + const project = [ + { name: 'fido', role: 'tester', source: 'local' }, + ]; + expect(mergeSessionCast(project, [])).toEqual(project); + }); + + it('empty project agents → returns all personal agents', () => { + const personal: PersonalAgentManifest[] = [ + { + name: 'ghost', + role: 'advisor', + source: 'personal', + personal: { origin: 'personal', sourceDir: '/p/ghost', ghostProtocol: true }, + }, + ]; + const merged = mergeSessionCast([], personal); + expect(merged.length).toBe(1); + expect(merged[0].name).toBe('ghost'); + }); + + it('no agents at all → returns empty array', () => { + expect(mergeSessionCast([], [])).toEqual([]); + }); + + it('project agent always wins on name collision (case-insensitive)', () => { + const project = [{ name: 'FIDO', role: 'tester', source: 'local' }]; + const personal: PersonalAgentManifest[] = [ + { + name: 'fido', + role: 'advisor', + source: 'personal', + personal: { origin: 'personal', sourceDir: '/p/fido', ghostProtocol: true }, + }, + { + name: 'unique', + role: 'helper', + source: 'personal', + personal: { origin: 'personal', sourceDir: '/p/unique', ghostProtocol: true }, + }, + ]; + const merged = mergeSessionCast(project, personal); + expect(merged.length).toBe(2); + expect(merged[0].name).toBe('FIDO'); + expect(merged[0].source).toBe('local'); + expect(merged[1].name).toBe('unique'); + }); +}); + +// --------------------------------------------------------------------------- +// 9. ensureSquadPathTriple with personalDir from discovery +// --------------------------------------------------------------------------- +describe('ensureSquadPathTriple — personal dir integration', () => { + const projDir = join(TEST_ROOT, 'proj'); + const teamDir = join(TEST_ROOT, 'team'); + const persDir = join(TEST_ROOT, 'pers'); + + beforeEach(() => { + cleanup(); + for (const d of [projDir, teamDir, persDir]) mkdirSync(d, { recursive: true }); + }); + afterEach(() => { cleanup(); }); + + it('accepts paths inside personalDir', () => { + const p = join(persDir, 'agents', 'x', 'charter.md'); + expect(ensureSquadPathTriple(p, projDir, teamDir, persDir)).toBe(p); + }); + + it('rejects paths outside all roots even with personalDir present', () => { + const evil = join(TEST_ROOT, 'evil.txt'); + expect(() => ensureSquadPathTriple(evil, projDir, teamDir, persDir)).toThrow( + /outside all allowed directories/, + ); + }); + + it('works when personalDir is null (no personal squad)', () => { + const valid = join(projDir, 'file.txt'); + expect(ensureSquadPathTriple(valid, projDir, teamDir, null)).toBe(valid); + }); +}); + +// --------------------------------------------------------------------------- +// 10. Charter metadata parsing edge cases (via resolvePersonalAgents) +// --------------------------------------------------------------------------- +describe('charter metadata parsing via resolvePersonalAgents', () => { + let personalDir: string; + + beforeEach(() => { + cleanup(); + mkdirSync(TEST_ROOT, { recursive: true }); + const globalDir = resolveGlobalSquadPath(); + personalDir = join(globalDir, 'personal-squad'); + }); + afterEach(() => { + if (existsSync(personalDir)) rmSync(personalDir, { recursive: true, force: true }); + cleanup(); + }); + + it('parses Role with extra whitespace', async () => { + const agentDir = join(personalDir, 'agents', 'spacey'); + mkdirSync(agentDir, { recursive: true }); + await writeFile( + join(agentDir, 'charter.md'), + '# Spacey\n\n**Role:** Staff Engineer \n', + 'utf-8', + ); + const agents = await resolvePersonalAgents(); + expect(agents.length).toBe(1); + expect(agents[0].role).toBe('Staff Engineer'); + }); + + it('sets source to "personal" and sourceDir correctly', async () => { + const agentDir = join(personalDir, 'agents', 'precise'); + mkdirSync(agentDir, { recursive: true }); + await writeFile( + join(agentDir, 'charter.md'), + '# Precise\n\n**Name:** Precise\n**Role:** Analyst\n', + 'utf-8', + ); + const agents = await resolvePersonalAgents(); + expect(agents.length).toBe(1); + expect(agents[0].source).toBe('personal'); + expect(agents[0].personal.sourceDir).toBe(agentDir); + }); + + it('multiple agents discovered and each has unique sourceDir', async () => { + const agentsDir = join(personalDir, 'agents'); + for (const name of ['alpha', 'beta', 'gamma']) { + const d = join(agentsDir, name); + mkdirSync(d, { recursive: true }); + await writeFile(join(d, 'charter.md'), `# ${name}\n\n**Role:** Worker\n`, 'utf-8'); + } + const agents = await resolvePersonalAgents(); + expect(agents.length).toBe(3); + const dirs = new Set(agents.map(a => a.personal.sourceDir)); + expect(dirs.size).toBe(3); + }); +}); From 14a3801cd48c3a42ce322d42ed3bb31996c8c084 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:31:03 -0700 Subject: [PATCH 2/3] fix(cli): personal squad init via npx discovers ~/.config/squad (#576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running `init --global` (e.g. via npx), the personal-squad/ directory was never created, so subsequent `init` in a repo could not discover the user's personal agents. Changes: - SDK: add ensurePersonalSquadDir() — idempotent helper that creates personal-squad/agents/ and config.json if missing. - CLI init --global: suppress workflows (no CI needed in global dir) and call ensurePersonalSquadDir() after scaffolding. - CLI repo-level init: detect existing personal squad and inform user. - personal.ts: reuse ensurePersonalSquadDir() instead of inline logic. - Tests: 3 new tests for ensurePersonalSquadDir(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 6 ++- .../squad-cli/src/cli/commands/personal.ts | 16 ++----- packages/squad-cli/src/cli/core/init.ts | 20 ++++++++- packages/squad-sdk/src/index.ts | 2 +- packages/squad-sdk/src/resolution.ts | 26 +++++++++++ test/resolution.test.ts | 44 ++++++++++++++++++- 6 files changed, 96 insertions(+), 18 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index a40ce04c6..c503accbd 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -271,11 +271,13 @@ async function main(): Promise { return; } - const dest = hasGlobal ? (await lazySquadSdk()).resolveGlobalSquadPath() : process.cwd(); + const sdkMod = hasGlobal ? await lazySquadSdk() : null; + const dest = hasGlobal ? sdkMod!.resolveGlobalSquadPath() : process.cwd(); const noWorkflows = args.includes('--no-workflows'); const sdk = args.includes('--sdk'); const roles = args.includes('--roles'); - runInit(dest, { includeWorkflows: !noWorkflows, sdk, roles }).catch(err => { + // Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad + runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal }).catch(err => { fatal(err.message); }); return; diff --git a/packages/squad-cli/src/cli/commands/personal.ts b/packages/squad-cli/src/cli/commands/personal.ts index cd9624dc4..7469faae9 100644 --- a/packages/squad-cli/src/cli/commands/personal.ts +++ b/packages/squad-cli/src/cli/commands/personal.ts @@ -12,7 +12,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { resolveGlobalSquadPath, resolvePersonalSquadDir } from '@bradygaster/squad-sdk/resolution'; +import { resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir } from '@bradygaster/squad-sdk/resolution'; import { resolvePersonalAgents } from '@bradygaster/squad-sdk/agents/personal'; import { success, warn, info, BOLD, RESET, DIM } from '../core/output.js'; import { fatal } from '../core/errors.js'; @@ -68,20 +68,10 @@ async function personalInit(): Promise { return; } - // Create directory structure - const agentsDir = path.join(personalDir, 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - - // Create config.json - const config = { - defaultModel: 'auto', - ghostProtocol: true, - }; - const configPath = path.join(personalDir, 'config.json'); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + const created = ensurePersonalSquadDir(); success('Personal squad initialized'); - info(` Path: ${personalDir}`); + info(` Path: ${created}`); info(` Add agents with: squad personal add --role `); } diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index bab5f0b53..2a0def976 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -11,7 +11,7 @@ import { success, BOLD, RESET, YELLOW, GREEN, DIM } from './output.js'; import { fatal } from './errors.js'; import { detectProjectType } from './project-type.js'; import { getPackageVersion, stampVersion } from './version.js'; -import { initSquad as sdkInitSquad, cleanupOrphanInitPrompt, type InitOptions } from '@bradygaster/squad-sdk'; +import { initSquad as sdkInitSquad, cleanupOrphanInitPrompt, ensurePersonalSquadDir, resolvePersonalSquadDir, type InitOptions } from '@bradygaster/squad-sdk'; const CYAN = '\x1b[36m'; @@ -104,6 +104,8 @@ export interface RunInitOptions { sdk?: boolean; /** If true, use built-in base roles instead of fictional universe casting (default: false) */ roles?: boolean; + /** If true, this is a global (personal squad) init — bootstrap personal-squad/ dir */ + isGlobal?: boolean; } /** @@ -296,6 +298,22 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi console.log(`${GREEN}${BOLD}Your team is ready.${RESET} Run ${CYAN}${BOLD}squad${RESET} to start.`); console.log(); + // ── Personal squad bridge ─────────────────────────────────────────── + if (options.isGlobal) { + // Global init: ensure personal-squad/ directory exists alongside .squad/ + const personalDir = ensurePersonalSquadDir(); + console.log(`${GREEN}${BOLD}✓${RESET} Personal squad initialized at ${DIM}${personalDir}${RESET}`); + console.log(`${DIM} Add agents with: squad personal add --role ${RESET}`); + console.log(); + } else { + // Repo init: inform user if personal squad is available + const personalDir = resolvePersonalSquadDir(); + if (personalDir) { + console.log(`${GREEN}${BOLD}✓${RESET} Personal squad detected — your personal agents will be available here.`); + console.log(); + } + } + if (squadInfo.isLegacy) { showDeprecationWarning(); } diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index dfac014fb..993af10e9 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -10,7 +10,7 @@ const pkg = require('../package.json'); export const VERSION: string = pkg.version; // Export public API -export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode } from './resolution.js'; +export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode } from './resolution.js'; export type { SquadDirConfig, ResolvedSquadPaths } from './resolution.js'; export * from './config/index.js'; export * from './agents/onboarding.js'; diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index ba6471d33..957deb912 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -340,6 +340,32 @@ export function resolvePersonalSquadDir(): string | null { return personalDir; } +/** + * Ensure the user's personal squad directory exists with the expected structure. + * Creates `personal-squad/agents/` and `personal-squad/config.json` if missing. + * + * Idempotent — safe to call multiple times. + * + * @returns Absolute path to the personal squad directory. + */ +export function ensurePersonalSquadDir(): string { + const globalDir = resolveGlobalSquadPath(); + const personalDir = path.join(globalDir, 'personal-squad'); + const agentsDir = path.join(personalDir, 'agents'); + + if (!fs.existsSync(agentsDir)) { + fs.mkdirSync(agentsDir, { recursive: true }); + } + + const configPath = path.join(personalDir, 'config.json'); + if (!fs.existsSync(configPath)) { + const config = { defaultModel: 'auto', ghostProtocol: true }; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + } + + return personalDir; +} + /** * Validate that a file path is within `.squad/` or the system temp directory. * diff --git a/test/resolution.test.ts b/test/resolution.test.ts index 8e072961e..f0c486baa 100644 --- a/test/resolution.test.ts +++ b/test/resolution.test.ts @@ -7,7 +7,7 @@ import { mkdirSync, rmSync, existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; import { tmpdir } from 'node:os'; -import { resolveSquad, resolveGlobalSquadPath, ensureSquadPath } from '@bradygaster/squad-sdk/resolution'; +import { resolveSquad, resolveGlobalSquadPath, ensureSquadPath, ensurePersonalSquadDir } from '@bradygaster/squad-sdk/resolution'; const TMP = join(process.cwd(), `.test-resolution-${randomBytes(4).toString('hex')}`); @@ -197,3 +197,45 @@ describe('ensureSquadPath()', () => { expect(() => ensureSquadPath(traversal, squadRoot)).toThrow(/outside the \.squad\/ directory/); }); }); + +describe('ensurePersonalSquadDir()', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('creates personal-squad/agents/ and config.json', () => { + const dir = ensurePersonalSquadDir(); + expect(existsSync(dir)).toBe(true); + expect(existsSync(join(dir, 'agents'))).toBe(true); + expect(existsSync(join(dir, 'config.json'))).toBe(true); + + const config = JSON.parse( + require('node:fs').readFileSync(join(dir, 'config.json'), 'utf-8'), + ); + expect(config.defaultModel).toBe('auto'); + expect(config.ghostProtocol).toBe(true); + }); + + it('is idempotent — does not overwrite existing config', () => { + const dir = ensurePersonalSquadDir(); + const configPath = join(dir, 'config.json'); + + // Write custom config + const custom = { defaultModel: 'gpt-4', ghostProtocol: true, custom: true }; + require('node:fs').writeFileSync(configPath, JSON.stringify(custom), 'utf-8'); + + // Call again — should not overwrite + ensurePersonalSquadDir(); + const config = JSON.parse( + require('node:fs').readFileSync(configPath, 'utf-8'), + ); + expect(config.custom).toBe(true); + expect(config.defaultModel).toBe('gpt-4'); + }); + + it('returns path inside resolveGlobalSquadPath()', () => { + const globalDir = resolveGlobalSquadPath(); + const personalDir = ensurePersonalSquadDir(); + expect(personalDir).toBe(join(globalDir, 'personal-squad')); + }); +}); From a609176a4c64cb5ae97a0154c36088017b8d6048 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:38:02 -0700 Subject: [PATCH 3/3] chore: sync templates and update agent history (#576) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/eecom/history.md | 16 + .squad/agents/fido/history.md | 20 + .../templates/skills/release-process/SKILL.md | 460 +++--------------- .../templates/skills/release-process/SKILL.md | 460 +++--------------- templates/machine-capabilities.md | 48 +- 5 files changed, 199 insertions(+), 805 deletions(-) diff --git a/.squad/agents/eecom/history.md b/.squad/agents/eecom/history.md index e89202bf4..2d26c73ba 100644 --- a/.squad/agents/eecom/history.md +++ b/.squad/agents/eecom/history.md @@ -242,3 +242,19 @@ Reviewed and merged PR #486 (two-layer signal handling + 22 tests). Improves gra ### Session 2 Summary (2026-03-22) Executed 3 tasks across 2 waves: economy mode (#500, PR #504), node:sqlite fix (#502, PR #506), rate limit UX (#464, PR #505). All PRs merged to dev. + + +### Personal Squad Init via npx (#576) (2026-03-23) + +**Context:** `init --global` (used via npx to set up personal squad) created a full `.squad/` structure at `~/.config/squad/` but never created the `personal-squad/` subdirectory. `resolvePersonalSquadDir()` looks for `personal-squad/`, so subsequent repo-level `init` couldn't discover the user's personal agents. + +**Root cause:** Two separate concepts - `init --global` scaffolds a full squad, `personal init` creates `personal-squad/`. The `--global` flag never bridged between them. + +**Fix:** +1. `resolution.ts` - Added `ensurePersonalSquadDir()` idempotent helper to SDK. +2. `cli-entry.ts` - `init --global` now suppresses workflows and passes `isGlobal` flag. +3. `init.ts` - After global init, calls `ensurePersonalSquadDir()`. After repo init, detects personal squad. +4. `personal.ts` - Refactored to reuse `ensurePersonalSquadDir()`. +5. `resolution.test.ts` - Added 3 tests. + +**Pattern:** `resolveGlobalSquadPath()` returns the container; `ensurePersonalSquadDir()` creates the subdirectory the rest of the system looks for. \ No newline at end of file diff --git a/.squad/agents/fido/history.md b/.squad/agents/fido/history.md index 17f0f414d..f84d20286 100644 --- a/.squad/agents/fido/history.md +++ b/.squad/agents/fido/history.md @@ -152,3 +152,23 @@ Pattern: Tests follow existing `test/cli/init.test.ts` and `test/cli/doctor.test Commit: 7660a27 on branch squad/579-init-scaffolding-hardening. +### Personal Squad Init Discovery Tests (#576) + +**Task:** Write tests for personal squad discovery and init flows (Issue #576 — npx init --global not discovering personal squad). + +**Test file:** `test/personal-squad-init.test.ts` — 35 tests, 10 describe blocks, all passing. + +**Coverage areas:** +1. `resolveGlobalSquadPath()` — platform-specific path resolution (Windows APPDATA, Linux XDG_CONFIG_HOME, consistency) +2. `resolvePersonalSquadDir()` — kill-switch (SQUAD_NO_PERSONAL), directory existence, npx-agnostic discovery +3. `personalInit` contract — directory structure creation, config.json shape, idempotency +4. `resolveSquadPaths()` — personalDir field inclusion, null when disabled +5. Edge: empty personal-squad dir (exists but no agents/) +6. Edge: partial state (agent dirs without charter.md, missing Role metadata defaults to "personal", stray files skipped) +7. `mergeSessionCast()` — project-wins precedence, case-insensitive collision, empty inputs +8. `ensureSquadPathTriple()` — personal dir in allowed roots, null personalDir graceful handling +9. Charter metadata parsing edge cases (whitespace trimming, sourceDir correctness, multi-agent discovery) + +**Key finding:** `resolvePersonalSquadDir()` is install-method-agnostic — it resolves from env vars and `os.homedir()`, never from `process.argv`. The npx issue (#576) is therefore NOT in path resolution but likely in the CLI command wiring or the `--global` flag routing. Tests confirm the SDK layer works correctly. + +**Commit:** c307187 on branch squad/576-personal-squad-init-npx diff --git a/packages/squad-cli/templates/skills/release-process/SKILL.md b/packages/squad-cli/templates/skills/release-process/SKILL.md index 12d644538..a11f3ad18 100644 --- a/packages/squad-cli/templates/skills/release-process/SKILL.md +++ b/packages/squad-cli/templates/skills/release-process/SKILL.md @@ -1,423 +1,125 @@ ---- -name: "release-process" -description: "Step-by-step release checklist for Squad — prevents v0.8.22-style disasters" -domain: "release-management" -confidence: "high" -source: "team-decision" ---- +# Release Process -## Context - -This is the **definitive release runbook** for Squad. Born from the v0.8.22 release disaster (4-part semver mangled by npm, draft release never triggered publish, wrong NPM_TOKEN type, 6+ hours of broken `latest` dist-tag). - -**Rule:** No agent releases Squad without following this checklist. No exceptions. No improvisation. - ---- - -## Pre-Release Validation - -Before starting ANY release work, validate the following: - -### 1. Version Number Validation - -**Rule:** Only 3-part semver (major.minor.patch) or prerelease (major.minor.patch-tag.N) are valid. 4-part versions (0.8.21.4) are NOT valid semver and npm will mangle them. +> Earned knowledge from the v0.9.0→v0.9.1 incident. Every agent involved in releases MUST read this before starting release work. -```bash -# Check version is valid semver -node -p "require('semver').valid('0.8.22')" -# Output: '0.8.22' = valid -# Output: null = INVALID, STOP - -# For prerelease versions -node -p "require('semver').valid('0.8.23-preview.1')" -# Output: '0.8.23-preview.1' = valid -``` - -**If `semver.valid()` returns `null`:** STOP. Fix the version. Do NOT proceed. - -### 2. NPM_TOKEN Verification - -**Rule:** NPM_TOKEN must be an **Automation token** (no 2FA required). User tokens with 2FA will fail in CI with EOTP errors. - -```bash -# Check token type (requires npm CLI authenticated) -npm token list -``` - -Look for: -- ✅ `read-write` tokens with NO 2FA requirement = Automation token (correct) -- ❌ Tokens requiring OTP = User token (WRONG, will fail in CI) - -**How to create an Automation token:** -1. Go to npmjs.com → Settings → Access Tokens -2. Click "Generate New Token" -3. Select **"Automation"** (NOT "Publish") -4. Copy token and save as GitHub secret: `NPM_TOKEN` - -**If using a User token:** STOP. Create an Automation token first. - -### 3. Branch and Tag State - -**Rule:** Release from `main` branch. Ensure clean state, no uncommitted changes, latest from origin. - -```bash -# Ensure on main and clean -git checkout main -git pull origin main -git status # Should show: "nothing to commit, working tree clean" - -# Check tag doesn't already exist -git tag -l "v0.8.22" -# Output should be EMPTY. If tag exists, release already done or collision. -``` - -**If tag exists:** STOP. Either release was already done, or there's a collision. Investigate before proceeding. - -### 4. Disable bump-build.mjs - -**Rule:** `bump-build.mjs` is for dev builds ONLY. It must NOT run during release builds (it increments build numbers, creating 4-part versions). - -```bash -# Set env var to skip bump-build.mjs -export SKIP_BUILD_BUMP=1 - -# Verify it's set -echo $SKIP_BUILD_BUMP -# Output: 1 -``` +## SCOPE -**For Windows PowerShell:** -```powershell -$env:SKIP_BUILD_BUMP = "1" -``` - -**If not set:** `bump-build.mjs` will run and mutate versions. This causes disasters (see v0.8.22). - ---- - -## Release Workflow - -### Step 1: Version Bump - -Update version in all 3 package.json files (root + both workspaces) in lockstep. - -```bash -# Set target version (no 'v' prefix) -VERSION="0.8.22" - -# Validate it's valid semver BEFORE proceeding -node -p "require('semver').valid('$VERSION')" -# Must output the version string, NOT null - -# Update all 3 package.json files -npm version $VERSION --workspaces --include-workspace-root --no-git-tag-version - -# Verify all 3 match -grep '"version"' package.json packages/squad-sdk/package.json packages/squad-cli/package.json -# All 3 should show: "version": "0.8.22" -``` - -**Checkpoint:** All 3 package.json files have identical versions. Run `semver.valid()` one more time to be sure. - -### Step 2: Commit and Tag - -```bash -# Commit version bump -git add package.json packages/squad-sdk/package.json packages/squad-cli/package.json -git commit -m "chore: bump version to $VERSION - -Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" - -# Create tag (with 'v' prefix) -git tag -a "v$VERSION" -m "Release v$VERSION" - -# Push commit and tag -git push origin main -git push origin "v$VERSION" -``` - -**Checkpoint:** Tag created and pushed. Verify with `git tag -l "v$VERSION"`. - -### Step 3: Create GitHub Release - -**CRITICAL:** Release must be **published**, NOT draft. Draft releases don't trigger `publish.yml` workflow. - -```bash -# Create GitHub Release (NOT draft) -gh release create "v$VERSION" \ - --title "v$VERSION" \ - --notes "Release notes go here" \ - --latest - -# Verify release is PUBLISHED (not draft) -gh release view "v$VERSION" -# Output should NOT contain "(draft)" -``` - -**If output contains `(draft)`:** STOP. Delete the release and recreate without `--draft` flag. - -```bash -# If you accidentally created a draft, fix it: -gh release edit "v$VERSION" --draft=false -``` +✅ THIS SKILL PRODUCES: +- Pre-release validation checks that prevent broken publishes +- Correct npm publish commands (never workspace-scoped) +- Fallback procedures when CI workflows fail +- Post-publish verification steps -**Checkpoint:** Release is published (NOT draft). The `release: published` event fired and triggered `publish.yml`. +❌ THIS SKILL DOES NOT PRODUCE: +- Feature implementation or test code +- Architecture decisions +- Documentation content -### Step 4: Monitor Workflow +## Confidence: high -The `publish.yml` workflow should start automatically within 10 seconds of release creation. +Established through the v0.9.1 incident (8-hour recovery). Every rule below is battle-tested. -```bash -# Watch workflow runs -gh run list --workflow=publish.yml --limit 1 - -# Get detailed status -gh run view --log -``` - -**Expected flow:** -1. `publish-sdk` job runs → publishes `@bradygaster/squad-sdk` -2. Verify step runs with retry loop (up to 5 attempts, 15s interval) to confirm SDK on npm registry -3. `publish-cli` job runs → publishes `@bradygaster/squad-cli` -4. Verify step runs with retry loop to confirm CLI on npm registry - -**If workflow fails:** Check the logs. Common issues: -- EOTP error = wrong NPM_TOKEN type (use Automation token) -- Verify step timeout = npm propagation delay (retry loop should handle this, but propagation can take up to 2 minutes in rare cases) -- Version mismatch = package.json version doesn't match tag - -**Checkpoint:** Both jobs succeeded. Workflow shows green checkmarks. - -### Step 5: Verify npm Publication - -Manually verify both packages are on npm with correct `latest` dist-tag. - -```bash -# Check SDK -npm view @bradygaster/squad-sdk version -# Output: 0.8.22 +## Context -npm dist-tag ls @bradygaster/squad-sdk -# Output should show: latest: 0.8.22 +Squad publishes two npm packages: `@bradygaster/squad-sdk` and `@bradygaster/squad-cli`. The release pipeline flows: dev → preview → main → GitHub Release → npm publish. Brady (project owner) triggers releases — the coordinator does NOT. -# Check CLI -npm view @bradygaster/squad-cli version -# Output: 0.8.22 +## Rules (Non-Negotiable) -npm dist-tag ls @bradygaster/squad-cli -# Output should show: latest: 0.8.22 -``` +### 1. Coordinator Does NOT Publish -**If versions don't match:** Something went wrong. Check workflow logs. DO NOT proceed with GitHub Release announcement until npm is correct. +The coordinator routes work and manages agents. It does NOT run `npm publish`, trigger release workflows, or make release decisions. Brady owns the release trigger. If an agent or the coordinator is asked to publish, escalate to Brady. -**Checkpoint:** Both packages show correct version. `latest` dist-tags point to the new version. +### 2. Pre-Publish Dependency Validation -### Step 6: Test Installation - -Verify packages can be installed from npm (real-world smoke test). +Before ANY release is tagged, scan every `packages/*/package.json` for: +- `file:` references (workspace leak — the v0.9.0 root cause) +- `link:` references +- Absolute paths in dependency values +- Non-semver version strings +**Command:** ```bash -# Create temp directory -mkdir /tmp/squad-release-test && cd /tmp/squad-release-test - -# Test SDK installation -npm init -y -npm install @bradygaster/squad-sdk -node -p "require('@bradygaster/squad-sdk/package.json').version" -# Output: 0.8.22 - -# Test CLI installation -npm install -g @bradygaster/squad-cli -squad --version -# Output: 0.8.22 - -# Cleanup -cd - -rm -rf /tmp/squad-release-test +grep -r '"file:\|"link:\|"/' packages/*/package.json ``` +If anything matches, STOP. Do not proceed. Fix the reference first. -**If installation fails:** npm registry issue or package metadata corruption. DO NOT announce release until this works. - -**Checkpoint:** Both packages install cleanly. Versions match. - -### Step 7: Sync dev to Next Preview +### 3. Never Use `npm -w` for Publishing -After main release, sync dev to the next preview version. +`npm -w packages/squad-sdk publish` hangs silently when 2FA is enabled. Always `cd` into the package directory: ```bash -# Checkout dev -git checkout dev -git pull origin dev - -# Bump to next preview version (e.g., 0.8.23-preview.1) -NEXT_VERSION="0.8.23-preview.1" - -# Validate semver -node -p "require('semver').valid('$NEXT_VERSION')" -# Must output the version string, NOT null - -# Update all 3 package.json files -npm version $NEXT_VERSION --workspaces --include-workspace-root --no-git-tag-version - -# Commit -git add package.json packages/squad-sdk/package.json packages/squad-cli/package.json -git commit -m "chore: bump dev to $NEXT_VERSION - -Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" - -# Push -git push origin dev +cd packages/squad-sdk && npm publish --access public +cd packages/squad-cli && npm publish --access public ``` -**Checkpoint:** dev branch now shows next preview version. Future dev builds will publish to `@preview` dist-tag. +### 4. Fallback Protocol ---- +If `workflow_dispatch` or the publish workflow fails: +1. Try once more (ONE retry, not four) +2. If it fails again → local publish immediately +3. Do NOT attempt GitHub UI file operations to fix workflow indexing +4. GitHub has a ~15min workflow cache TTL after file renames/deletes — waiting helps, retrying doesn't -## Manual Publish (Fallback) - -If `publish.yml` workflow fails or needs to be bypassed, use `workflow_dispatch` to manually trigger publish. +### 5. Post-Publish Smoke Test +After every publish, verify in a clean shell: ```bash -# Trigger manual publish -gh workflow run publish.yml -f version="0.8.22" - -# Monitor the run -gh run watch +npm install -g @bradygaster/squad-cli@latest +squad --version # should match published version +squad doctor # should pass in a test repo ``` -**Rule:** Only use this if automated publish failed. Always investigate why automation failed and fix it for next release. +If the smoke test fails, rollback immediately. ---- +### 6. npm Token Must Be Automation Type -## Rollback Procedure +NPM_TOKEN in CI must be an Automation token (not a user token with 2FA prompts). User tokens with `auth-and-writes` 2FA cause silent hangs in non-interactive environments. -If a release is broken and needs to be rolled back: +### 7. No Draft GitHub Releases -### 1. Unpublish from npm (Nuclear Option) +Never create draft GitHub Releases. The `release: published` event only fires when a release is published — drafts don't trigger the npm publish workflow. -**WARNING:** npm unpublish is time-limited (24 hours) and leaves the version slot burned. Only use if version is critically broken. +### 8. Version Format -```bash -# Unpublish (requires npm owner privileges) -npm unpublish @bradygaster/squad-sdk@0.8.22 -npm unpublish @bradygaster/squad-cli@0.8.22 -``` - -### 2. Deprecate on npm (Preferred) - -**Preferred approach:** Mark version as deprecated, publish a hotfix. +Semantic versioning only: `MAJOR.MINOR.PATCH` (e.g., `0.9.1`). Four-part versions like `0.8.21.4` are NOT valid semver and will break npm publish. -```bash -# Deprecate broken version -npm deprecate @bradygaster/squad-sdk@0.8.22 "Broken release, use 0.8.22.1 instead" -npm deprecate @bradygaster/squad-cli@0.8.22 "Broken release, use 0.8.22.1 instead" - -# Publish hotfix version -# (Follow this runbook with version 0.8.22.1) -``` +### 9. SKIP_BUILD_BUMP=1 in CI -### 3. Delete GitHub Release and Tag +Set this environment variable in all CI build steps to prevent the build script from mutating versions during CI runs. -```bash -# Delete GitHub Release -gh release delete "v0.8.22" --yes +## Release Checklist (Quick Reference) -# Delete tag locally and remotely -git tag -d "v0.8.22" -git push origin --delete "v0.8.22" ``` - -### 4. Revert Commit on main - -```bash -# Revert version bump commit -git checkout main -git revert HEAD -git push origin main +□ All tests passing on dev +□ No file:/link: references in packages/*/package.json +□ CHANGELOG.md updated +□ Version bumps committed (node -e script) +□ npm auth verified (Automation token) +□ No draft GitHub Releases pending +□ Local build + test: npm run build && npx vitest run +□ Push dev → CI green +□ Promote dev → preview (squad-promote workflow) +□ Preview CI green (squad-preview validates) +□ Promote preview → main +□ squad-release auto-creates GitHub Release +□ squad-npm-publish auto-triggers +□ Monitor publish workflow +□ Post-publish smoke test ``` -**Checkpoint:** Tag and release deleted. main branch reverted. npm packages deprecated or unpublished. - ---- - -## Common Failure Modes - -### EOTP Error (npm OTP Required) - -**Symptom:** Workflow fails with `EOTP` error. -**Root cause:** NPM_TOKEN is a User token with 2FA enabled. CI can't provide OTP. -**Fix:** Replace NPM_TOKEN with an Automation token (no 2FA). See "NPM_TOKEN Verification" above. - -### Verify Step 404 (npm Propagation Delay) - -**Symptom:** Verify step fails with 404 even though publish succeeded. -**Root cause:** npm registry propagation delay (5-30 seconds). -**Fix:** Verify step now has retry loop (5 attempts, 15s interval). Should auto-resolve. If not, wait 2 minutes and re-run workflow. - -### Version Mismatch (package.json ≠ tag) - -**Symptom:** Verify step fails with "Package version (X) does not match target version (Y)". -**Root cause:** package.json version doesn't match the tag version. -**Fix:** Ensure all 3 package.json files were updated in Step 1. Re-run `npm version` if needed. - -### 4-Part Version Mangled by npm - -**Symptom:** Published version on npm doesn't match package.json (e.g., 0.8.21.4 became 0.8.2-1.4). -**Root cause:** 4-part versions are NOT valid semver. npm's parser misinterprets them. -**Fix:** NEVER use 4-part versions. Only 3-part (0.8.22) or prerelease (0.8.23-preview.1). Run `semver.valid()` before ANY commit. - -### Draft Release Didn't Trigger Workflow - -**Symptom:** Release created but `publish.yml` never ran. -**Root cause:** Release was created as a draft. Draft releases don't emit `release: published` event. -**Fix:** Edit release and change to published: `gh release edit "v$VERSION" --draft=false`. Workflow should trigger immediately. - ---- - -## Validation Checklist - -Before starting ANY release, confirm: - -- [ ] Version is valid semver: `node -p "require('semver').valid('VERSION')"` returns the version string (NOT null) -- [ ] NPM_TOKEN is an Automation token (no 2FA): `npm token list` shows `read-write` without OTP requirement -- [ ] Branch is clean: `git status` shows "nothing to commit, working tree clean" -- [ ] Tag doesn't exist: `git tag -l "vVERSION"` returns empty -- [ ] `SKIP_BUILD_BUMP=1` is set: `echo $SKIP_BUILD_BUMP` returns `1` - -Before creating GitHub Release: - -- [ ] All 3 package.json files have matching versions: `grep '"version"' package.json packages/*/package.json` -- [ ] Commit is pushed: `git log origin/main..main` returns empty -- [ ] Tag is pushed: `git ls-remote --tags origin vVERSION` returns the tag SHA - -After GitHub Release: - -- [ ] Release is published (NOT draft): `gh release view "vVERSION"` output doesn't contain "(draft)" -- [ ] Workflow is running: `gh run list --workflow=publish.yml --limit 1` shows "in_progress" - -After workflow completes: - -- [ ] Both jobs succeeded: Workflow shows green checkmarks -- [ ] SDK on npm: `npm view @bradygaster/squad-sdk version` returns correct version -- [ ] CLI on npm: `npm view @bradygaster/squad-cli version` returns correct version -- [ ] `latest` tags correct: `npm dist-tag ls @bradygaster/squad-sdk` shows `latest: VERSION` -- [ ] Packages install: `npm install @bradygaster/squad-cli` succeeds - -After dev sync: - -- [ ] dev branch has next preview version: `git show dev:package.json | grep version` shows next preview - ---- - -## Post-Mortem Reference +## Known Gotchas -This skill was created after the v0.8.22 release disaster. Full retrospective: `.squad/decisions/inbox/keaton-v0822-retrospective.md` +| Gotcha | Impact | Mitigation | +|--------|--------|------------| +| npm workspaces rewrite `"*"` → `"file:../path"` | Broken global installs | Preflight scan in CI (squad-npm-publish.yml) | +| GitHub Actions workflow cache (~15min TTL) | 422 on workflow_dispatch after file renames | Wait 15min or use local publish fallback | +| `npm -w publish` hangs with 2FA | Silent hang, no error | Never use `-w` for publish | +| Draft GitHub Releases | npm publish workflow doesn't trigger | Never create drafts | +| User npm tokens with 2FA | EOTP errors in CI | Use Automation token type | -**Key learnings:** -1. No release without a runbook = improvisation = disaster -2. Semver validation is mandatory — 4-part versions break npm -3. NPM_TOKEN type matters — User tokens with 2FA fail in CI -4. Draft releases are a footgun — they don't trigger automation -5. Retry logic is essential — npm propagation takes time +## Related -**Never again.** +- Issues: #556–#564 (release:next) +- Retro: `.squad/decisions/inbox/surgeon-v091-retrospective.md` +- CI audit: `.squad/decisions/inbox/booster-ci-audit.md` +- Playbook (for Brady's review): session files/release-playbook.md diff --git a/packages/squad-sdk/templates/skills/release-process/SKILL.md b/packages/squad-sdk/templates/skills/release-process/SKILL.md index 12d644538..a11f3ad18 100644 --- a/packages/squad-sdk/templates/skills/release-process/SKILL.md +++ b/packages/squad-sdk/templates/skills/release-process/SKILL.md @@ -1,423 +1,125 @@ ---- -name: "release-process" -description: "Step-by-step release checklist for Squad — prevents v0.8.22-style disasters" -domain: "release-management" -confidence: "high" -source: "team-decision" ---- +# Release Process -## Context - -This is the **definitive release runbook** for Squad. Born from the v0.8.22 release disaster (4-part semver mangled by npm, draft release never triggered publish, wrong NPM_TOKEN type, 6+ hours of broken `latest` dist-tag). - -**Rule:** No agent releases Squad without following this checklist. No exceptions. No improvisation. - ---- - -## Pre-Release Validation - -Before starting ANY release work, validate the following: - -### 1. Version Number Validation - -**Rule:** Only 3-part semver (major.minor.patch) or prerelease (major.minor.patch-tag.N) are valid. 4-part versions (0.8.21.4) are NOT valid semver and npm will mangle them. +> Earned knowledge from the v0.9.0→v0.9.1 incident. Every agent involved in releases MUST read this before starting release work. -```bash -# Check version is valid semver -node -p "require('semver').valid('0.8.22')" -# Output: '0.8.22' = valid -# Output: null = INVALID, STOP - -# For prerelease versions -node -p "require('semver').valid('0.8.23-preview.1')" -# Output: '0.8.23-preview.1' = valid -``` - -**If `semver.valid()` returns `null`:** STOP. Fix the version. Do NOT proceed. - -### 2. NPM_TOKEN Verification - -**Rule:** NPM_TOKEN must be an **Automation token** (no 2FA required). User tokens with 2FA will fail in CI with EOTP errors. - -```bash -# Check token type (requires npm CLI authenticated) -npm token list -``` - -Look for: -- ✅ `read-write` tokens with NO 2FA requirement = Automation token (correct) -- ❌ Tokens requiring OTP = User token (WRONG, will fail in CI) - -**How to create an Automation token:** -1. Go to npmjs.com → Settings → Access Tokens -2. Click "Generate New Token" -3. Select **"Automation"** (NOT "Publish") -4. Copy token and save as GitHub secret: `NPM_TOKEN` - -**If using a User token:** STOP. Create an Automation token first. - -### 3. Branch and Tag State - -**Rule:** Release from `main` branch. Ensure clean state, no uncommitted changes, latest from origin. - -```bash -# Ensure on main and clean -git checkout main -git pull origin main -git status # Should show: "nothing to commit, working tree clean" - -# Check tag doesn't already exist -git tag -l "v0.8.22" -# Output should be EMPTY. If tag exists, release already done or collision. -``` - -**If tag exists:** STOP. Either release was already done, or there's a collision. Investigate before proceeding. - -### 4. Disable bump-build.mjs - -**Rule:** `bump-build.mjs` is for dev builds ONLY. It must NOT run during release builds (it increments build numbers, creating 4-part versions). - -```bash -# Set env var to skip bump-build.mjs -export SKIP_BUILD_BUMP=1 - -# Verify it's set -echo $SKIP_BUILD_BUMP -# Output: 1 -``` +## SCOPE -**For Windows PowerShell:** -```powershell -$env:SKIP_BUILD_BUMP = "1" -``` - -**If not set:** `bump-build.mjs` will run and mutate versions. This causes disasters (see v0.8.22). - ---- - -## Release Workflow - -### Step 1: Version Bump - -Update version in all 3 package.json files (root + both workspaces) in lockstep. - -```bash -# Set target version (no 'v' prefix) -VERSION="0.8.22" - -# Validate it's valid semver BEFORE proceeding -node -p "require('semver').valid('$VERSION')" -# Must output the version string, NOT null - -# Update all 3 package.json files -npm version $VERSION --workspaces --include-workspace-root --no-git-tag-version - -# Verify all 3 match -grep '"version"' package.json packages/squad-sdk/package.json packages/squad-cli/package.json -# All 3 should show: "version": "0.8.22" -``` - -**Checkpoint:** All 3 package.json files have identical versions. Run `semver.valid()` one more time to be sure. - -### Step 2: Commit and Tag - -```bash -# Commit version bump -git add package.json packages/squad-sdk/package.json packages/squad-cli/package.json -git commit -m "chore: bump version to $VERSION - -Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" - -# Create tag (with 'v' prefix) -git tag -a "v$VERSION" -m "Release v$VERSION" - -# Push commit and tag -git push origin main -git push origin "v$VERSION" -``` - -**Checkpoint:** Tag created and pushed. Verify with `git tag -l "v$VERSION"`. - -### Step 3: Create GitHub Release - -**CRITICAL:** Release must be **published**, NOT draft. Draft releases don't trigger `publish.yml` workflow. - -```bash -# Create GitHub Release (NOT draft) -gh release create "v$VERSION" \ - --title "v$VERSION" \ - --notes "Release notes go here" \ - --latest - -# Verify release is PUBLISHED (not draft) -gh release view "v$VERSION" -# Output should NOT contain "(draft)" -``` - -**If output contains `(draft)`:** STOP. Delete the release and recreate without `--draft` flag. - -```bash -# If you accidentally created a draft, fix it: -gh release edit "v$VERSION" --draft=false -``` +✅ THIS SKILL PRODUCES: +- Pre-release validation checks that prevent broken publishes +- Correct npm publish commands (never workspace-scoped) +- Fallback procedures when CI workflows fail +- Post-publish verification steps -**Checkpoint:** Release is published (NOT draft). The `release: published` event fired and triggered `publish.yml`. +❌ THIS SKILL DOES NOT PRODUCE: +- Feature implementation or test code +- Architecture decisions +- Documentation content -### Step 4: Monitor Workflow +## Confidence: high -The `publish.yml` workflow should start automatically within 10 seconds of release creation. +Established through the v0.9.1 incident (8-hour recovery). Every rule below is battle-tested. -```bash -# Watch workflow runs -gh run list --workflow=publish.yml --limit 1 - -# Get detailed status -gh run view --log -``` - -**Expected flow:** -1. `publish-sdk` job runs → publishes `@bradygaster/squad-sdk` -2. Verify step runs with retry loop (up to 5 attempts, 15s interval) to confirm SDK on npm registry -3. `publish-cli` job runs → publishes `@bradygaster/squad-cli` -4. Verify step runs with retry loop to confirm CLI on npm registry - -**If workflow fails:** Check the logs. Common issues: -- EOTP error = wrong NPM_TOKEN type (use Automation token) -- Verify step timeout = npm propagation delay (retry loop should handle this, but propagation can take up to 2 minutes in rare cases) -- Version mismatch = package.json version doesn't match tag - -**Checkpoint:** Both jobs succeeded. Workflow shows green checkmarks. - -### Step 5: Verify npm Publication - -Manually verify both packages are on npm with correct `latest` dist-tag. - -```bash -# Check SDK -npm view @bradygaster/squad-sdk version -# Output: 0.8.22 +## Context -npm dist-tag ls @bradygaster/squad-sdk -# Output should show: latest: 0.8.22 +Squad publishes two npm packages: `@bradygaster/squad-sdk` and `@bradygaster/squad-cli`. The release pipeline flows: dev → preview → main → GitHub Release → npm publish. Brady (project owner) triggers releases — the coordinator does NOT. -# Check CLI -npm view @bradygaster/squad-cli version -# Output: 0.8.22 +## Rules (Non-Negotiable) -npm dist-tag ls @bradygaster/squad-cli -# Output should show: latest: 0.8.22 -``` +### 1. Coordinator Does NOT Publish -**If versions don't match:** Something went wrong. Check workflow logs. DO NOT proceed with GitHub Release announcement until npm is correct. +The coordinator routes work and manages agents. It does NOT run `npm publish`, trigger release workflows, or make release decisions. Brady owns the release trigger. If an agent or the coordinator is asked to publish, escalate to Brady. -**Checkpoint:** Both packages show correct version. `latest` dist-tags point to the new version. +### 2. Pre-Publish Dependency Validation -### Step 6: Test Installation - -Verify packages can be installed from npm (real-world smoke test). +Before ANY release is tagged, scan every `packages/*/package.json` for: +- `file:` references (workspace leak — the v0.9.0 root cause) +- `link:` references +- Absolute paths in dependency values +- Non-semver version strings +**Command:** ```bash -# Create temp directory -mkdir /tmp/squad-release-test && cd /tmp/squad-release-test - -# Test SDK installation -npm init -y -npm install @bradygaster/squad-sdk -node -p "require('@bradygaster/squad-sdk/package.json').version" -# Output: 0.8.22 - -# Test CLI installation -npm install -g @bradygaster/squad-cli -squad --version -# Output: 0.8.22 - -# Cleanup -cd - -rm -rf /tmp/squad-release-test +grep -r '"file:\|"link:\|"/' packages/*/package.json ``` +If anything matches, STOP. Do not proceed. Fix the reference first. -**If installation fails:** npm registry issue or package metadata corruption. DO NOT announce release until this works. - -**Checkpoint:** Both packages install cleanly. Versions match. - -### Step 7: Sync dev to Next Preview +### 3. Never Use `npm -w` for Publishing -After main release, sync dev to the next preview version. +`npm -w packages/squad-sdk publish` hangs silently when 2FA is enabled. Always `cd` into the package directory: ```bash -# Checkout dev -git checkout dev -git pull origin dev - -# Bump to next preview version (e.g., 0.8.23-preview.1) -NEXT_VERSION="0.8.23-preview.1" - -# Validate semver -node -p "require('semver').valid('$NEXT_VERSION')" -# Must output the version string, NOT null - -# Update all 3 package.json files -npm version $NEXT_VERSION --workspaces --include-workspace-root --no-git-tag-version - -# Commit -git add package.json packages/squad-sdk/package.json packages/squad-cli/package.json -git commit -m "chore: bump dev to $NEXT_VERSION - -Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" - -# Push -git push origin dev +cd packages/squad-sdk && npm publish --access public +cd packages/squad-cli && npm publish --access public ``` -**Checkpoint:** dev branch now shows next preview version. Future dev builds will publish to `@preview` dist-tag. +### 4. Fallback Protocol ---- +If `workflow_dispatch` or the publish workflow fails: +1. Try once more (ONE retry, not four) +2. If it fails again → local publish immediately +3. Do NOT attempt GitHub UI file operations to fix workflow indexing +4. GitHub has a ~15min workflow cache TTL after file renames/deletes — waiting helps, retrying doesn't -## Manual Publish (Fallback) - -If `publish.yml` workflow fails or needs to be bypassed, use `workflow_dispatch` to manually trigger publish. +### 5. Post-Publish Smoke Test +After every publish, verify in a clean shell: ```bash -# Trigger manual publish -gh workflow run publish.yml -f version="0.8.22" - -# Monitor the run -gh run watch +npm install -g @bradygaster/squad-cli@latest +squad --version # should match published version +squad doctor # should pass in a test repo ``` -**Rule:** Only use this if automated publish failed. Always investigate why automation failed and fix it for next release. +If the smoke test fails, rollback immediately. ---- +### 6. npm Token Must Be Automation Type -## Rollback Procedure +NPM_TOKEN in CI must be an Automation token (not a user token with 2FA prompts). User tokens with `auth-and-writes` 2FA cause silent hangs in non-interactive environments. -If a release is broken and needs to be rolled back: +### 7. No Draft GitHub Releases -### 1. Unpublish from npm (Nuclear Option) +Never create draft GitHub Releases. The `release: published` event only fires when a release is published — drafts don't trigger the npm publish workflow. -**WARNING:** npm unpublish is time-limited (24 hours) and leaves the version slot burned. Only use if version is critically broken. +### 8. Version Format -```bash -# Unpublish (requires npm owner privileges) -npm unpublish @bradygaster/squad-sdk@0.8.22 -npm unpublish @bradygaster/squad-cli@0.8.22 -``` - -### 2. Deprecate on npm (Preferred) - -**Preferred approach:** Mark version as deprecated, publish a hotfix. +Semantic versioning only: `MAJOR.MINOR.PATCH` (e.g., `0.9.1`). Four-part versions like `0.8.21.4` are NOT valid semver and will break npm publish. -```bash -# Deprecate broken version -npm deprecate @bradygaster/squad-sdk@0.8.22 "Broken release, use 0.8.22.1 instead" -npm deprecate @bradygaster/squad-cli@0.8.22 "Broken release, use 0.8.22.1 instead" - -# Publish hotfix version -# (Follow this runbook with version 0.8.22.1) -``` +### 9. SKIP_BUILD_BUMP=1 in CI -### 3. Delete GitHub Release and Tag +Set this environment variable in all CI build steps to prevent the build script from mutating versions during CI runs. -```bash -# Delete GitHub Release -gh release delete "v0.8.22" --yes +## Release Checklist (Quick Reference) -# Delete tag locally and remotely -git tag -d "v0.8.22" -git push origin --delete "v0.8.22" ``` - -### 4. Revert Commit on main - -```bash -# Revert version bump commit -git checkout main -git revert HEAD -git push origin main +□ All tests passing on dev +□ No file:/link: references in packages/*/package.json +□ CHANGELOG.md updated +□ Version bumps committed (node -e script) +□ npm auth verified (Automation token) +□ No draft GitHub Releases pending +□ Local build + test: npm run build && npx vitest run +□ Push dev → CI green +□ Promote dev → preview (squad-promote workflow) +□ Preview CI green (squad-preview validates) +□ Promote preview → main +□ squad-release auto-creates GitHub Release +□ squad-npm-publish auto-triggers +□ Monitor publish workflow +□ Post-publish smoke test ``` -**Checkpoint:** Tag and release deleted. main branch reverted. npm packages deprecated or unpublished. - ---- - -## Common Failure Modes - -### EOTP Error (npm OTP Required) - -**Symptom:** Workflow fails with `EOTP` error. -**Root cause:** NPM_TOKEN is a User token with 2FA enabled. CI can't provide OTP. -**Fix:** Replace NPM_TOKEN with an Automation token (no 2FA). See "NPM_TOKEN Verification" above. - -### Verify Step 404 (npm Propagation Delay) - -**Symptom:** Verify step fails with 404 even though publish succeeded. -**Root cause:** npm registry propagation delay (5-30 seconds). -**Fix:** Verify step now has retry loop (5 attempts, 15s interval). Should auto-resolve. If not, wait 2 minutes and re-run workflow. - -### Version Mismatch (package.json ≠ tag) - -**Symptom:** Verify step fails with "Package version (X) does not match target version (Y)". -**Root cause:** package.json version doesn't match the tag version. -**Fix:** Ensure all 3 package.json files were updated in Step 1. Re-run `npm version` if needed. - -### 4-Part Version Mangled by npm - -**Symptom:** Published version on npm doesn't match package.json (e.g., 0.8.21.4 became 0.8.2-1.4). -**Root cause:** 4-part versions are NOT valid semver. npm's parser misinterprets them. -**Fix:** NEVER use 4-part versions. Only 3-part (0.8.22) or prerelease (0.8.23-preview.1). Run `semver.valid()` before ANY commit. - -### Draft Release Didn't Trigger Workflow - -**Symptom:** Release created but `publish.yml` never ran. -**Root cause:** Release was created as a draft. Draft releases don't emit `release: published` event. -**Fix:** Edit release and change to published: `gh release edit "v$VERSION" --draft=false`. Workflow should trigger immediately. - ---- - -## Validation Checklist - -Before starting ANY release, confirm: - -- [ ] Version is valid semver: `node -p "require('semver').valid('VERSION')"` returns the version string (NOT null) -- [ ] NPM_TOKEN is an Automation token (no 2FA): `npm token list` shows `read-write` without OTP requirement -- [ ] Branch is clean: `git status` shows "nothing to commit, working tree clean" -- [ ] Tag doesn't exist: `git tag -l "vVERSION"` returns empty -- [ ] `SKIP_BUILD_BUMP=1` is set: `echo $SKIP_BUILD_BUMP` returns `1` - -Before creating GitHub Release: - -- [ ] All 3 package.json files have matching versions: `grep '"version"' package.json packages/*/package.json` -- [ ] Commit is pushed: `git log origin/main..main` returns empty -- [ ] Tag is pushed: `git ls-remote --tags origin vVERSION` returns the tag SHA - -After GitHub Release: - -- [ ] Release is published (NOT draft): `gh release view "vVERSION"` output doesn't contain "(draft)" -- [ ] Workflow is running: `gh run list --workflow=publish.yml --limit 1` shows "in_progress" - -After workflow completes: - -- [ ] Both jobs succeeded: Workflow shows green checkmarks -- [ ] SDK on npm: `npm view @bradygaster/squad-sdk version` returns correct version -- [ ] CLI on npm: `npm view @bradygaster/squad-cli version` returns correct version -- [ ] `latest` tags correct: `npm dist-tag ls @bradygaster/squad-sdk` shows `latest: VERSION` -- [ ] Packages install: `npm install @bradygaster/squad-cli` succeeds - -After dev sync: - -- [ ] dev branch has next preview version: `git show dev:package.json | grep version` shows next preview - ---- - -## Post-Mortem Reference +## Known Gotchas -This skill was created after the v0.8.22 release disaster. Full retrospective: `.squad/decisions/inbox/keaton-v0822-retrospective.md` +| Gotcha | Impact | Mitigation | +|--------|--------|------------| +| npm workspaces rewrite `"*"` → `"file:../path"` | Broken global installs | Preflight scan in CI (squad-npm-publish.yml) | +| GitHub Actions workflow cache (~15min TTL) | 422 on workflow_dispatch after file renames | Wait 15min or use local publish fallback | +| `npm -w publish` hangs with 2FA | Silent hang, no error | Never use `-w` for publish | +| Draft GitHub Releases | npm publish workflow doesn't trigger | Never create drafts | +| User npm tokens with 2FA | EOTP errors in CI | Use Automation token type | -**Key learnings:** -1. No release without a runbook = improvisation = disaster -2. Semver validation is mandatory — 4-part versions break npm -3. NPM_TOKEN type matters — User tokens with 2FA fail in CI -4. Draft releases are a footgun — they don't trigger automation -5. Retry logic is essential — npm propagation takes time +## Related -**Never again.** +- Issues: #556–#564 (release:next) +- Retro: `.squad/decisions/inbox/surgeon-v091-retrospective.md` +- CI audit: `.squad/decisions/inbox/booster-ci-audit.md` +- Playbook (for Brady's review): session files/release-playbook.md diff --git a/templates/machine-capabilities.md b/templates/machine-capabilities.md index fd709643c..b770fd04b 100644 --- a/templates/machine-capabilities.md +++ b/templates/machine-capabilities.md @@ -59,11 +59,7 @@ Ralph will log skipped issues: ## Kubernetes Integration -Machine capabilities support two deployment modes on Kubernetes: - -### Mode A — Agent-per-node (default) - -One Ralph process per Kubernetes node. Each reads the node-local `machine-capabilities.json`. Use `nodeSelector` to pin Ralphs to nodes with the right hardware. +On Kubernetes, machine capabilities map to node labels: ```yaml # Node labels (set by capability DaemonSet or manually) @@ -76,46 +72,4 @@ spec: node.squad.dev/gpu: "true" ``` -No extra environment variables needed — this is the default mode. - -### Mode B — Squad-per-pod - -Multiple full Squad instances run as separate pods (on the same or different nodes). Each pod gets its own identity via the `SQUAD_POD_ID` environment variable, which enables pod-specific capability manifests. - -```yaml -# Deployment spec for squad-per-pod mode -spec: - replicas: 3 - template: - spec: - containers: - - name: squad - env: - - name: SQUAD_POD_ID - valueFrom: - fieldRef: - fieldPath: metadata.name - - name: SQUAD_DEPLOYMENT_MODE - value: squad-per-pod -``` - -When `SQUAD_POD_ID` is set and `SQUAD_DEPLOYMENT_MODE` is `squad-per-pod`, Ralph looks for a pod-specific manifest first: - -1. `.squad/machine-capabilities-{podId}.json` (pod-specific) -2. `.squad/machine-capabilities.json` (shared fallback) -3. `~/.squad/machine-capabilities.json` (user home fallback) -4. `null` (opt-in — all issues pass through) - -Example pod-specific manifest (`.squad/machine-capabilities-squad-worker-7b4f6.json`): - -```json -{ - "machine": "squad-worker-7b4f6", - "capabilities": ["gpu", "docker", "azure-cli"], - "missing": ["browser", "onedrive"], - "lastUpdated": "2026-06-01T00:00:00Z", - "podId": "squad-worker-7b4f6" -} -``` - A DaemonSet can run capability discovery on each node and maintain labels automatically. See the [squad-on-aks](https://github.com/tamirdresher/squad-on-aks) project for a complete Kubernetes deployment example. \ No newline at end of file