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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .squad/agents/fido/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
📌 **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.

33 changes: 30 additions & 3 deletions packages/squad-sdk/src/config/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,33 @@ export async function initSquad(options: InitOptions): Promise<InitResult> {
}
}

// -------------------------------------------------------------------------
// 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
// -------------------------------------------------------------------------
Expand All @@ -710,7 +737,7 @@ export async function initSquad(options: InitOptions): Promise<InitResult> {
// 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';
Expand All @@ -729,7 +756,7 @@ export async function initSquad(options: InitOptions): Promise<InitResult> {
// 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);
Expand Down Expand Up @@ -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;
Expand Down
307 changes: 307 additions & 0 deletions test/init-scaffolding.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading