From bf1e3d4fb135d00f139c2f2103c7111e29fbfe65 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:13:35 -0700 Subject: [PATCH 1/3] fix(cli): scaffold casting dir + silence no-remote errors on init (#579) - Add stdio: ['pipe','pipe','pipe'] to all execFileSync('git remote get-url origin') calls in SDK init so stderr doesn't leak when no remote is configured - Scaffold .squad/casting/policy.json, registry.json, history.json from SDK templates during init (falls back to inline defaults) - Respect skipExisting: pre-existing casting files are never overwritten - Add tests for casting file scaffolding and content validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-sdk/src/config/init.ts | 33 ++++++++++++++-- test/init-sdk.test.ts | 54 ++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index 7e5228d9f..ee83ea8af 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -701,6 +701,33 @@ export async function initSquad(options: InitOptions): Promise { } } + // ------------------------------------------------------------------------- + // Scaffold .squad/casting/ files (policy, registry, history) + // ------------------------------------------------------------------------- + + const castingDir = join(squadDir, 'casting'); + const castingFiles: Array<{ name: string; templateName: string; fallback: string }> = [ + { name: 'policy.json', templateName: 'casting-policy.json', fallback: JSON.stringify({ casting_policy_version: '1.1', allowlist_universes: [], universe_capacity: {} }, null, 2) + '\n' }, + { name: 'registry.json', templateName: 'casting-registry.json', fallback: JSON.stringify({ agents: {} }, null, 2) + '\n' }, + { name: 'history.json', templateName: 'casting-history.json', fallback: JSON.stringify({ universe_usage_history: [], assignment_cast_snapshots: {} }, null, 2) + '\n' }, + ]; + + for (const cf of castingFiles) { + const dest = join(castingDir, cf.name); + if (!existsSync(dest)) { + // Try to copy from SDK templates first, fall back to inline defaults + const templateSrc = templatesDir ? join(templatesDir, cf.templateName) : null; + if (templateSrc && existsSync(templateSrc)) { + cpSync(templateSrc, dest); + } else { + await writeFile(dest, cf.fallback, 'utf-8'); + } + createdFiles.push(toRelativePath(dest)); + } else { + skippedFiles.push(toRelativePath(dest)); + } + } + // ------------------------------------------------------------------------- // Create .squad/config.json for squad settings // ------------------------------------------------------------------------- @@ -710,7 +737,7 @@ export async function initSquad(options: InitOptions): Promise { // Detect platform from git remote for config let detectedPlatform: string | undefined; try { - const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: teamRoot, encoding: 'utf-8' }).trim(); + const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: teamRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); const remoteUrlLower = remoteUrl.toLowerCase(); if (remoteUrlLower.includes('dev.azure.com') || remoteUrlLower.includes('visualstudio.com') || remoteUrlLower.includes('ssh.dev.azure.com')) { detectedPlatform = 'azure-devops'; @@ -729,7 +756,7 @@ export async function initSquad(options: InitOptions): Promise { // to discover available work item types for the project. let introspectedTypes: string[] | undefined; try { - const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: teamRoot, encoding: 'utf-8' }).trim(); + const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: teamRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); // Parse org/project from remote URL for introspection const httpsMatch = remoteUrl.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git/i); const sshMatch = remoteUrl.match(/ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\//i); @@ -1036,7 +1063,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre let isGitHub = true; try { - const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: teamRoot, encoding: 'utf-8' }).trim(); + const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: teamRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); const remoteUrlLower = remoteUrl.toLowerCase(); if (remoteUrlLower.includes('dev.azure.com') || remoteUrlLower.includes('visualstudio.com') || remoteUrlLower.includes('ssh.dev.azure.com')) { isGitHub = false; diff --git a/test/init-sdk.test.ts b/test/init-sdk.test.ts index 63227d251..f37d0248a 100644 --- a/test/init-sdk.test.ts +++ b/test/init-sdk.test.ts @@ -129,7 +129,12 @@ describe('squad init --sdk flag', () => { // Assert: .squad/casting/ exists (if created during init) const castingPath = join(tempDir, '.squad', 'casting'); - // May not exist in minimal init, so just check structure + expect(existsSync(castingPath)).toBe(true); + + // Assert: casting files are scaffolded (#579) + expect(existsSync(join(castingPath, 'policy.json'))).toBe(true); + expect(existsSync(join(castingPath, 'registry.json'))).toBe(true); + expect(existsSync(join(castingPath, 'history.json'))).toBe(true); // Assert: .squad/decisions/ exists expect(existsSync(join(tempDir, '.squad', 'decisions'))).toBe(true); @@ -144,6 +149,53 @@ describe('squad init --sdk flag', () => { expect(existsSync(join(tempDir, '.squad', 'identity'))).toBe(true); }); + it('init scaffolds casting files with valid JSON (#579)', async () => { + const options: InitOptions = { + teamRoot: tempDir, + projectName: 'test-squad', + agents: [{ name: 'edie', role: 'Engineer' }], + configFormat: 'markdown', + }; + + await initSquad(options); + + const castingDir = join(tempDir, '.squad', 'casting'); + + // policy.json should have casting_policy_version + const policy = JSON.parse(await readFile(join(castingDir, 'policy.json'), 'utf-8')); + expect(policy).toHaveProperty('casting_policy_version'); + expect(policy).toHaveProperty('allowlist_universes'); + + // registry.json should have agents object + const registry = JSON.parse(await readFile(join(castingDir, 'registry.json'), 'utf-8')); + expect(registry).toHaveProperty('agents'); + + // history.json should have empty arrays + const history = JSON.parse(await readFile(join(castingDir, 'history.json'), 'utf-8')); + expect(history).toHaveProperty('universe_usage_history'); + expect(history).toHaveProperty('assignment_cast_snapshots'); + }); + + it('init does not overwrite existing casting files', async () => { + const castingDir = join(tempDir, '.squad', 'casting'); + const { mkdirSync, writeFileSync } = await import('fs'); + mkdirSync(castingDir, { recursive: true }); + writeFileSync(join(castingDir, 'registry.json'), '{"agents":{"custom":"data"}}', 'utf-8'); + + const options: InitOptions = { + teamRoot: tempDir, + projectName: 'test-squad', + agents: [{ name: 'edie', role: 'Engineer' }], + configFormat: 'markdown', + }; + + const result = await initSquad(options); + + // Should have skipped the existing file + const registry = JSON.parse(await readFile(join(castingDir, 'registry.json'), 'utf-8')); + expect(registry.agents).toEqual({ custom: 'data' }); + }); + it('backward compat: configFormat typescript still works', async () => { const options: InitOptions = { teamRoot: tempDir, From d4ec9d8b38263d8c14c4b619df4bc93fce6c505d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:15:01 -0700 Subject: [PATCH 2/3] test: add init scaffolding completeness tests (#579) Verify that init produces a complete .squad/ directory, particularly: - casting/ subtree (registry.json, policy.json, history.json) - no-remote resilience (git repo without origin configured) - doctor validation passes after a fresh init 15 tests covering initSquad() SDK, runInit() CLI, and doctor checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/init-scaffolding.test.ts | 307 ++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 test/init-scaffolding.test.ts diff --git a/test/init-scaffolding.test.ts b/test/init-scaffolding.test.ts new file mode 100644 index 000000000..a5d9d5816 --- /dev/null +++ b/test/init-scaffolding.test.ts @@ -0,0 +1,307 @@ +/** + * Init scaffolding completeness tests (#579) + * + * Verifies that `initSquad()` and `runInit()` produce a complete .squad/ + * directory — particularly the casting/ subtree that doctor validates. + * Also confirms init works without errors in repos that have no remote. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdir, rm, readFile } from 'fs/promises'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { randomBytes } from 'crypto'; +import { execFileSync } from 'child_process'; +import { initSquad } from '@bradygaster/squad-sdk'; +import type { InitOptions } from '@bradygaster/squad-sdk'; +import { runInit } from '@bradygaster/squad-cli/core/init'; +import { runDoctor } from '@bradygaster/squad-cli/commands/doctor'; +import type { DoctorCheck } from '@bradygaster/squad-cli/commands/doctor'; + +const TEST_ROOT = join(process.cwd(), `.test-init-scaffold-${randomBytes(4).toString('hex')}`); + +/** Create a bare git repo at the given path (no remote). */ +function gitInit(dir: string): void { + execFileSync('git', ['init'], { + cwd: dir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + // Configure git identity so commits don't fail + execFileSync('git', ['config', 'user.email', 'test@test.local'], { + cwd: dir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + execFileSync('git', ['config', 'user.name', 'Test'], { + cwd: dir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +/** Default InitOptions for SDK-level initSquad(). */ +function sdkOptions(teamRoot: string): InitOptions { + return { + teamRoot, + projectName: 'scaffold-test', + agents: [{ name: 'edie', role: 'Engineer' }], + configFormat: 'markdown', + includeWorkflows: false, + }; +} + +// ─── Casting directory scaffolding (SDK initSquad) ───────────────────── + +describe('casting directory scaffolding — initSquad()', () => { + beforeEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + await mkdir(TEST_ROOT, { recursive: true }); + }); + + afterEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + }); + + it('creates .squad/casting/ directory', async () => { + await initSquad(sdkOptions(TEST_ROOT)); + expect(existsSync(join(TEST_ROOT, '.squad', 'casting'))).toBe(true); + }); + + it('creates .squad/casting/registry.json as valid JSON', async () => { + await initSquad(sdkOptions(TEST_ROOT)); + + const filePath = join(TEST_ROOT, '.squad', 'casting', 'registry.json'); + expect(existsSync(filePath)).toBe(true); + + const content = await readFile(filePath, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toBeDefined(); + // Registry is an object (with agents key) or an array — both are valid + expect(typeof parsed).toBe('object'); + }); + + it('creates .squad/casting/policy.json as valid JSON', async () => { + await initSquad(sdkOptions(TEST_ROOT)); + + const filePath = join(TEST_ROOT, '.squad', 'casting', 'policy.json'); + expect(existsSync(filePath)).toBe(true); + + const content = await readFile(filePath, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toBeDefined(); + expect(typeof parsed).toBe('object'); + }); + + it('creates .squad/casting/history.json as valid JSON', async () => { + await initSquad(sdkOptions(TEST_ROOT)); + + const filePath = join(TEST_ROOT, '.squad', 'casting', 'history.json'); + expect(existsSync(filePath)).toBe(true); + + const content = await readFile(filePath, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed).toBeDefined(); + expect(typeof parsed).toBe('object'); + }); + + it('does not overwrite existing casting files on re-init', async () => { + await initSquad(sdkOptions(TEST_ROOT)); + + // Modify registry.json to detect overwrite + const registryPath = join(TEST_ROOT, '.squad', 'casting', 'registry.json'); + const original = await readFile(registryPath, 'utf-8'); + const modified = JSON.stringify({ agents: { sentinel: true } }); + await rm(registryPath); + const { writeFile } = await import('fs/promises'); + await writeFile(registryPath, modified, 'utf-8'); + + // Re-init + await initSquad(sdkOptions(TEST_ROOT)); + + const after = await readFile(registryPath, 'utf-8'); + expect(after).toContain('sentinel'); + }); +}); + +// ─── Casting directory scaffolding (CLI runInit) ─────────────────────── + +describe('casting directory scaffolding — runInit()', () => { + beforeEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + await mkdir(TEST_ROOT, { recursive: true }); + }); + + afterEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + }); + + it('creates all three casting files via CLI init', async () => { + await runInit(TEST_ROOT); + + for (const file of ['registry.json', 'policy.json', 'history.json']) { + const filePath = join(TEST_ROOT, '.squad', 'casting', file); + expect(existsSync(filePath), `${file} should exist`).toBe(true); + + const content = await readFile(filePath, 'utf-8'); + // Should parse without throwing + const parsed = JSON.parse(content); + expect(parsed).toBeDefined(); + } + }); +}); + +// ─── No-remote resilience ────────────────────────────────────────────── + +describe('no-remote resilience (#579)', () => { + beforeEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + await mkdir(TEST_ROOT, { recursive: true }); + }); + + afterEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + }); + + it('initSquad succeeds in a git repo with no remote', async () => { + gitInit(TEST_ROOT); + + // Confirm no remote exists + let hasRemote = true; + try { + execFileSync('git', ['remote', 'get-url', 'origin'], { + cwd: TEST_ROOT, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + hasRemote = false; + } + expect(hasRemote).toBe(false); + + // Init should not throw + await expect(initSquad(sdkOptions(TEST_ROOT))).resolves.toBeDefined(); + + // Verify scaffolding completed + expect(existsSync(join(TEST_ROOT, '.squad'))).toBe(true); + expect(existsSync(join(TEST_ROOT, '.squad', 'casting', 'registry.json'))).toBe(true); + }); + + it('initSquad succeeds in a brand-new git repo (just git init)', async () => { + gitInit(TEST_ROOT); + + const result = await initSquad(sdkOptions(TEST_ROOT)); + expect(result.createdFiles.length).toBeGreaterThan(0); + expect(result.squadDir).toBeTruthy(); + }); + + it('runInit succeeds in a git repo with no remote', async () => { + gitInit(TEST_ROOT); + + // Should complete without error + await expect(runInit(TEST_ROOT)).resolves.toBeUndefined(); + + // Verify key output files + expect(existsSync(join(TEST_ROOT, '.squad'))).toBe(true); + expect(existsSync(join(TEST_ROOT, '.github', 'agents', 'squad.agent.md'))).toBe(true); + }); + + it('initSquad succeeds when git is not initialized at all', async () => { + // TEST_ROOT is a plain directory — no git init + await expect(initSquad(sdkOptions(TEST_ROOT))).resolves.toBeDefined(); + expect(existsSync(join(TEST_ROOT, '.squad', 'casting', 'registry.json'))).toBe(true); + }); +}); + +// ─── Doctor validation after init ────────────────────────────────────── + +describe('doctor passes after init (#579)', () => { + beforeEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + await mkdir(TEST_ROOT, { recursive: true }); + }); + + afterEach(async () => { + if (existsSync(TEST_ROOT)) { + await rm(TEST_ROOT, { recursive: true, force: true }); + } + }); + + it('doctor reports casting/registry.json as pass after initSquad()', async () => { + await initSquad(sdkOptions(TEST_ROOT)); + + const checks = await runDoctor(TEST_ROOT); + const registryCheck = checks.find( + (c: DoctorCheck) => c.name === 'casting/registry.json exists', + ); + expect(registryCheck).toBeDefined(); + expect(registryCheck?.status).toBe('pass'); + }); + + it('doctor has zero failures after initSquad()', async () => { + await initSquad(sdkOptions(TEST_ROOT)); + + const checks = await runDoctor(TEST_ROOT); + const failures = checks.filter((c: DoctorCheck) => c.status === 'fail'); + // All core checks should pass after a fresh init + expect(failures).toEqual([]); + }); + + it('doctor reports casting/registry.json as pass after runInit()', async () => { + await runInit(TEST_ROOT); + + const checks = await runDoctor(TEST_ROOT); + const registryCheck = checks.find( + (c: DoctorCheck) => c.name === 'casting/registry.json exists', + ); + expect(registryCheck).toBeDefined(); + expect(registryCheck?.status).toBe('pass'); + }); + + it('doctor fails when casting/registry.json is missing', async () => { + await initSquad(sdkOptions(TEST_ROOT)); + + // Remove registry.json + await rm(join(TEST_ROOT, '.squad', 'casting', 'registry.json')); + + const checks = await runDoctor(TEST_ROOT); + const registryCheck = checks.find( + (c: DoctorCheck) => c.name === 'casting/registry.json exists', + ); + expect(registryCheck).toBeDefined(); + expect(registryCheck?.status).toBe('fail'); + }); + + it('doctor fails when casting/registry.json is invalid JSON', async () => { + await initSquad(sdkOptions(TEST_ROOT)); + + // Corrupt registry.json + const { writeFile } = await import('fs/promises'); + await writeFile( + join(TEST_ROOT, '.squad', 'casting', 'registry.json'), + 'NOT VALID JSON {{{', + 'utf-8', + ); + + const checks = await runDoctor(TEST_ROOT); + const registryCheck = checks.find( + (c: DoctorCheck) => c.name === 'casting/registry.json exists', + ); + expect(registryCheck).toBeDefined(); + expect(registryCheck?.status).toBe('fail'); + }); +}); From 279b1a0703c82adc24751a3357af1d5c5eb55ea5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:15:22 -0700 Subject: [PATCH 3/3] docs: update FIDO history with #579 scaffolding test learnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/fido/history.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.squad/agents/fido/history.md b/.squad/agents/fido/history.md index f1ffb81db..17f0f414d 100644 --- a/.squad/agents/fido/history.md +++ b/.squad/agents/fido/history.md @@ -137,4 +137,18 @@ Extracted inline regex-based agent name parsing from `shell/index.ts` into a tes **Learning:** Inline regex logic in UI code is untestable and fragile. Extracting to a pure function with explicit inputs (description string + known names array) makes it trivially testable and enables VOX's parallel fix to land cleanly. -📌 **Team update (2026-03-23T23:15Z):** Orchestration complete. Agent name extraction refactor shipped: FIDO's parser module (30 tests, all passing), VOX's 3-tier cascading patterns, Procedures' spawn template standardization. All decisions merged to decisions.md. Agent IDs now display correctly in Copilot CLI. Canonical patterns: `agent-name-parser.ts` is source of truth for extraction logic. \ No newline at end of file +📌 **Team update (2026-03-23T23:15Z):** Orchestration complete. Agent name extraction refactor shipped: FIDO's parser module (30 tests, all passing), VOX's 3-tier cascading patterns, Procedures' spawn template standardization. All decisions merged to decisions.md. Agent IDs now display correctly in Copilot CLI. Canonical patterns: `agent-name-parser.ts` is source of truth for extraction logic. +### Init Scaffolding Completeness Tests (#579) + +Added `test/init-scaffolding.test.ts` — 15 tests covering three gaps exposed by issue #579: + +1. **Casting directory scaffolding** — After `initSquad()` and `runInit()`, verifies `.squad/casting/` directory and all three JSON files (registry.json, policy.json, history.json) exist and parse as valid JSON. Also confirms re-init does not overwrite existing casting files. + +2. **No-remote resilience** — Confirms init succeeds without errors when: git repo has no remote configured, brand-new `git init` repo, or no git at all. Uses `execFileSync` to create isolated git repos in temp dirs. + +3. **Doctor validation after init** — Runs `runDoctor()` against a freshly-initialized directory and asserts zero failures, specifically that `casting/registry.json exists` check passes. Also tests negative cases (missing file → fail, corrupt JSON → fail). + +Pattern: Tests follow existing `test/cli/init.test.ts` and `test/cli/doctor.test.ts` conventions — vitest, `randomBytes` temp dirs in cwd, imports from compiled dist via package exports (`@bradygaster/squad-cli/core/init`, `@bradygaster/squad-cli/commands/doctor`, `@bradygaster/squad-sdk`). + +Commit: 7660a27 on branch squad/579-init-scaffolding-hardening. +