Skip to content

Commit ce326d5

Browse files
bradygasterCopilot
andcommitted
fix: prevent agent file collision in init, upgrade, and consult paths
All code paths that write squad.agent.md now check for an existing exported coordinator (squad.md with generated marker) first. If one exists, squad.agent.md is skipped — the exported file supersedes it. Previously only the export direction was guarded (export renames legacy to .bak). This closes the reverse vectors: init, upgrade, and consult could blindly write squad.agent.md alongside an already-exported squad.md, causing a collision. Vectors fixed: - squad init: skips squad.agent.md when exported coordinator present - squad upgrade: skips refresh when exported coordinator present - squad consult: skips agent file creation when exported coordinator present Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 942af1d commit ce326d5

7 files changed

Lines changed: 150 additions & 17 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@bradygaster/squad-sdk': patch
3+
'@bradygaster/squad-cli': patch
4+
---
5+
6+
Prevent agent file collision: init, upgrade, and consult-mode now skip writing squad.agent.md when an exported coordinator (squad.md) already exists in .github/agents/

‎packages/squad-cli/src/cli/core/upgrade.ts‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import path from 'node:path';
88
import { execFileSync } from 'node:child_process';
9-
import { FSStorageProvider } from '@bradygaster/squad-sdk';
9+
import { FSStorageProvider, wouldCollideWithExport } from '@bradygaster/squad-sdk';
1010
import { success, warn, info, dim, bold } from './output.js';
1111
import { fatal } from './errors.js';
1212
import { detectSquadDir } from './detect-squad-dir.js';
@@ -774,9 +774,11 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
774774
filesUpdated.push(`workflows (${wfFiles.length} files)`);
775775
}
776776

777-
// Refresh squad.agent.md
777+
// Refresh squad.agent.md (skip if exported coordinator squad.md exists)
778778
const agentSrc = path.join(templatesDir, 'squad.agent.md.template');
779-
if (storage.existsSync(agentSrc)) {
779+
if (wouldCollideWithExport(dest)) {
780+
info('Exported coordinator (squad.md) detected — skipping squad.agent.md refresh');
781+
} else if (storage.existsSync(agentSrc)) {
780782
storage.mkdirSync(path.dirname(agentDest), { recursive: true });
781783
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp);
782784
success('upgraded squad.agent.md');
@@ -796,20 +798,24 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
796798
};
797799
}
798800

799-
// Upgrade squad.agent.md
801+
// Upgrade squad.agent.md (skip if exported coordinator squad.md exists)
800802
const templatesDir = getTemplatesDir();
801803
const agentSrc = path.join(templatesDir, 'squad.agent.md.template');
804+
const fromLabel = oldVersion === '0.0.0' || !oldVersion ? 'unknown' : oldVersion;
802805

803-
if (!storage.existsSync(agentSrc)) {
804-
fatal('squad.agent.md.template not found in templates — installation may be corrupted');
805-
}
806+
if (wouldCollideWithExport(dest)) {
807+
info('Exported coordinator (squad.md) detected — skipping squad.agent.md write');
808+
} else {
809+
if (!storage.existsSync(agentSrc)) {
810+
fatal('squad.agent.md.template not found in templates — installation may be corrupted');
811+
}
806812

807-
storage.mkdirSync(path.dirname(agentDest), { recursive: true });
808-
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp);
813+
storage.mkdirSync(path.dirname(agentDest), { recursive: true });
814+
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp);
809815

810-
const fromLabel = oldVersion === '0.0.0' || !oldVersion ? 'unknown' : oldVersion;
811-
success(`upgraded coordinator from ${fromLabel} to ${cliVersion}`);
812-
filesUpdated.push('squad.agent.md');
816+
success(`upgraded coordinator from ${fromLabel} to ${cliVersion}`);
817+
filesUpdated.push('squad.agent.md');
818+
}
813819

814820
// Upgrade squad-owned files from template manifest
815821
// Exclude squad.agent.md — already copied and version-stamped above
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Agent file collision detection — zero dependencies.
3+
*
4+
* Prevents squad.agent.md (legacy/init) and squad.md (exported coordinator)
5+
* from coexisting in .github/agents/. Only ONE should be present at a time.
6+
*/
7+
8+
import fs from 'node:fs';
9+
import path from 'node:path';
10+
11+
/**
12+
* Marker embedded by `squad export agent` in generated squad.md files.
13+
* Must match the marker in repo-native/write-coordinator-agent.ts exactly.
14+
*/
15+
const EXPORTED_COORDINATOR_MARKER = '<!-- generated by squad export: do not edit by hand -->';
16+
17+
/**
18+
* Check whether an exported coordinator agent (squad.md) exists in the
19+
* .github/agents/ directory. If it does, writing squad.agent.md would
20+
* cause a collision — both files register the "squad" agent identity.
21+
*
22+
* @param root - The repository root (or agentFileRoot).
23+
* @returns The path to the exported file if it exists, or null.
24+
*/
25+
export function detectExportedCoordinator(root: string): string | null {
26+
const exportedPath = path.join(root, '.github', 'agents', 'squad.md');
27+
28+
try {
29+
if (!fs.existsSync(exportedPath)) return null;
30+
const content = fs.readFileSync(exportedPath, 'utf-8');
31+
if (content.includes(EXPORTED_COORDINATOR_MARKER)) {
32+
return exportedPath;
33+
}
34+
} catch {
35+
// Can't read → treat as not present
36+
}
37+
38+
return null;
39+
}
40+
41+
/**
42+
* Returns true if writing squad.agent.md at the given root would collide
43+
* with an already-exported coordinator (squad.md).
44+
*/
45+
export function wouldCollideWithExport(root: string): boolean {
46+
return detectExportedCoordinator(root) !== null;
47+
}

‎packages/squad-sdk/src/config/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './migration.js';
1313
export * from './markdown-migration.js';
1414
export * from './legacy-fallback.js';
1515
export * from './feature-audit.js';
16+
export * from './agent-collision.js';

‎packages/squad-sdk/src/config/init.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { SubSquadDefinition } from '../streams/types.js';
1919
import { ENGINEERING_ROLE_IDS } from '../roles/catalog.js';
2020
import { getRoleById } from '../roles/index.js';
2121
import { ensureMemoryGovernanceDefaults } from '../memory/index.js';
22+
import { wouldCollideWithExport } from './agent-collision.js';
2223

2324
// ============================================================================
2425
// Manifest-Curated Skills (must stay in sync with TEMPLATE_MANIFEST in CLI)
@@ -1253,7 +1254,12 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre
12531254
// -------------------------------------------------------------------------
12541255

12551256
const agentFile = join(options.agentFileRoot ?? teamRoot, '.github', 'agents', 'squad.agent.md');
1256-
if (!storage.existsSync(agentFile) || !skipExisting) {
1257+
const agentFileRoot = options.agentFileRoot ?? teamRoot;
1258+
if (wouldCollideWithExport(agentFileRoot)) {
1259+
// An exported coordinator (squad.md) already exists — writing squad.agent.md
1260+
// would cause a collision. Skip silently; the exported file supersedes.
1261+
skippedFiles.push(toRelativePath(agentFile));
1262+
} else if (!storage.existsSync(agentFile) || !skipExisting) {
12571263
if (templatesDir && storage.existsSync(join(templatesDir, 'squad.agent.md.template'))) {
12581264
let agentContent = storage.readSync(join(templatesDir, 'squad.agent.md.template')) ?? '';
12591265
if (mcpConfigMode === 'agent-frontmatter') {

‎packages/squad-sdk/src/sharing/consult.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { execSync } from 'node:child_process';
2222
import { FSStorageProvider } from '../storage/fs-storage-provider.js';
2323
import type { AgentHistory, HistoryEntry } from './history-split.js';
2424
import { resolveGlobalSquadPath } from '../resolution.js';
25+
import { wouldCollideWithExport } from '../config/agent-collision.js';
2526

2627
const storage = new FSStorageProvider();
2728

@@ -438,11 +439,14 @@ export async function setupConsultMode(
438439
patchScribeCharterForConsultMode(squadDir);
439440

440441
// Create .github/agents/squad.agent.md for `gh copilot --agent squad`
441-
const agentDir = path.dirname(agentFile);
442-
if (!storage.existsSync(agentDir)) {
443-
storage.mkdirSync(agentDir, { recursive: true });
442+
// Skip if an exported coordinator (squad.md) already exists — it supersedes.
443+
if (!wouldCollideWithExport(projectRoot)) {
444+
const agentDir = path.dirname(agentFile);
445+
if (!storage.existsSync(agentDir)) {
446+
storage.mkdirSync(agentDir, { recursive: true });
447+
}
448+
storage.writeSync(agentFile, getConsultAgentContent(projectName));
444449
}
445-
storage.writeSync(agentFile, getConsultAgentContent(projectName));
446450

447451
// Add .squad/ and .github/agents/squad.agent.md to .git/info/exclude
448452
const excludeDir = path.dirname(gitExclude);

‎test/sdk/agent-collision.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Agent file collision prevention tests.
3+
*
4+
* Ensures squad.agent.md is NEVER written when an exported coordinator
5+
* (squad.md with the generated marker) already exists in .github/agents/.
6+
*/
7+
8+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9+
import fs from 'node:fs';
10+
import path from 'node:path';
11+
import os from 'node:os';
12+
import { wouldCollideWithExport, detectExportedCoordinator } from '../../packages/squad-sdk/src/config/agent-collision.js';
13+
14+
describe('agent-collision detection', () => {
15+
let tmpDir: string;
16+
17+
beforeEach(() => {
18+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-collision-'));
19+
});
20+
21+
afterEach(() => {
22+
fs.rmSync(tmpDir, { recursive: true, force: true });
23+
});
24+
25+
it('returns null when no .github/agents/ exists', () => {
26+
expect(detectExportedCoordinator(tmpDir)).toBeNull();
27+
expect(wouldCollideWithExport(tmpDir)).toBe(false);
28+
});
29+
30+
it('returns null when .github/agents/ exists but no squad.md', () => {
31+
fs.mkdirSync(path.join(tmpDir, '.github', 'agents'), { recursive: true });
32+
expect(detectExportedCoordinator(tmpDir)).toBeNull();
33+
expect(wouldCollideWithExport(tmpDir)).toBe(false);
34+
});
35+
36+
it('returns null when squad.md exists but lacks the generated marker', () => {
37+
const agentsDir = path.join(tmpDir, '.github', 'agents');
38+
fs.mkdirSync(agentsDir, { recursive: true });
39+
fs.writeFileSync(path.join(agentsDir, 'squad.md'), '# My custom agent file\n');
40+
expect(detectExportedCoordinator(tmpDir)).toBeNull();
41+
expect(wouldCollideWithExport(tmpDir)).toBe(false);
42+
});
43+
44+
it('detects collision when squad.md has the generated marker', () => {
45+
const agentsDir = path.join(tmpDir, '.github', 'agents');
46+
fs.mkdirSync(agentsDir, { recursive: true });
47+
const exported = '---\nname: Squad\n---\n<!-- generated by squad export: do not edit by hand -->\n# Coordinator\n';
48+
fs.writeFileSync(path.join(agentsDir, 'squad.md'), exported);
49+
50+
const result = detectExportedCoordinator(tmpDir);
51+
expect(result).toBe(path.join(agentsDir, 'squad.md'));
52+
expect(wouldCollideWithExport(tmpDir)).toBe(true);
53+
});
54+
55+
it('detects collision even with marker deep in file', () => {
56+
const agentsDir = path.join(tmpDir, '.github', 'agents');
57+
fs.mkdirSync(agentsDir, { recursive: true });
58+
const exported = '---\nname: Squad\n---\n\n# Big file\n\nLots of content...\n\n<!-- generated by squad export: do not edit by hand -->\n\nMore content.\n';
59+
fs.writeFileSync(path.join(agentsDir, 'squad.md'), exported);
60+
61+
expect(wouldCollideWithExport(tmpDir)).toBe(true);
62+
});
63+
});

0 commit comments

Comments
 (0)