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
5 changes: 5 additions & 0 deletions .changeset/preserve-copilot-instructions-on-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bradygaster/squad-cli": patch
---

Preserve user-authored `.github/copilot-instructions.md` content during `squad upgrade`, `squad init`, and `squad copilot` by managing the Squad instructions section with `<!-- squad:copilot-instructions:start -->` and `<!-- squad:copilot-instructions:end -->` markers instead of overwriting the entire file (#2186).
3 changes: 3 additions & 0 deletions .squad-templates/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<!-- squad:copilot-instructions:start -->
# Copilot Coding Agent — Squad Instructions

You are working on a project that uses **Squad**, an AI team framework. When picking up issues autonomously, follow these guidelines.
Expand Down Expand Up @@ -62,3 +63,5 @@ If you make a decision that affects other team members, write it to:
.squad/decisions/inbox/copilot-{brief-slug}.md
```
The Scribe will merge it into the shared decisions file.
<!-- squad:copilot-instructions:end -->

18 changes: 13 additions & 5 deletions packages/squad-cli/src/cli/commands/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { FSStorageProvider } from '@bradygaster/squad-sdk';
const storage = new FSStorageProvider();
import { success, dim, bold, info, BOLD, RESET, DIM } from '../core/output.js';
import { effectiveSquadDir } from '../core/effective-squad-dir.js';
import { removeCopilotInstructionsBlock, syncCopilotInstructionsFile, writeManagedText } from '../core/managed-file.js';
import {
readTeamMd,
writeTeamMd,
Expand Down Expand Up @@ -50,11 +51,18 @@ export async function runCopilot(dest: string, flags: CopilotFlags): Promise<voi
writeTeamMd(stateDir, content);
success('Removed @copilot from the team roster');

// Remove copilot-instructions.md
// Remove Squad-managed section from copilot-instructions.md (or delete if no user content remains)
const instructionsDest = path.join(dest, '.github', 'copilot-instructions.md');
if (storage.existsSync(instructionsDest)) {
storage.deleteSync(instructionsDest);
success('Removed .github/copilot-instructions.md');
const existingInstructions = storage.readSync(instructionsDest) ?? '';
const remainingInstructions = removeCopilotInstructionsBlock(existingInstructions);
if (remainingInstructions === null) {
storage.deleteSync(instructionsDest);
success('Removed .github/copilot-instructions.md');
} else {
writeManagedText(instructionsDest, remainingInstructions);
success('Removed Squad section from .github/copilot-instructions.md');
}
}
return;
}
Expand All @@ -80,7 +88,7 @@ export async function runCopilot(dest: string, flags: CopilotFlags): Promise<voi
success('Auto-assign enabled — squad-labeled issues will be assigned to @copilot');
}

// Copy copilot-instructions.md from templates
// Upsert Squad section into copilot-instructions.md from templates
// Templates are at the root of the package (../../../templates from dist/cli/commands/)
const currentFileUrl = new URL(import.meta.url);
const currentFilePath = currentFileUrl.pathname.startsWith('/') && process.platform === 'win32'
Expand All @@ -92,7 +100,7 @@ export async function runCopilot(dest: string, flags: CopilotFlags): Promise<voi

if (storage.existsSync(instructionsSrc)) {
storage.mkdirSync(path.dirname(instructionsDest), { recursive: true });
storage.copySync(instructionsSrc, instructionsDest);
syncCopilotInstructionsFile(instructionsSrc, instructionsDest);
success('.github/copilot-instructions.md');
}

Expand Down
5 changes: 3 additions & 2 deletions packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { installGitHooks } from '../commands/install-hooks.js';
import { liftInitMutableStateOntoOrphan } from '../commands/migrate-backend.js';
import { projectSquadStateMcpSpec, resolveSquadStateMcpSpec } from './mcp-spec.js';
import { describeMcpSpec, ensureUserOwnedTemplates } from './upgrade.js';
import { syncCopilotInstructionsFile } from './managed-file.js';
import { getTemplatesDir } from './templates.js';
import { ensureSquadStateMcpInRoot, tombstoneStaleSquadStateInProjectMcp } from './mcp-root.js';
import {
Expand Down Expand Up @@ -546,9 +547,9 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
const instructionsSrc = path.join(templatesSrc, 'copilot-instructions.md');
const instructionsDest = path.join(dest, '.github', 'copilot-instructions.md');

if (storage.existsSync(instructionsSrc) && !storage.existsSync(instructionsDest)) {
if (storage.existsSync(instructionsSrc)) {
storage.mkdirSync(path.dirname(instructionsDest), { recursive: true });
storage.copySync(instructionsSrc, instructionsDest);
syncCopilotInstructionsFile(instructionsSrc, instructionsDest);
success('.github/copilot-instructions.md');
}
} else {
Expand Down
113 changes: 112 additions & 1 deletion packages/squad-cli/src/cli/core/managed-file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
import { extname } from 'node:path';

export const COPILOT_INSTRUCTIONS_START = '<!-- squad:copilot-instructions:start -->';
export const COPILOT_INSTRUCTIONS_END = '<!-- squad:copilot-instructions:end -->';
const LEGACY_COPILOT_INSTRUCTIONS_HEADING = '# Copilot Coding Agent — Squad Instructions';
const LEGACY_COPILOT_INSTRUCTIONS_TAIL = 'The Scribe will merge it into the shared decisions file.';

const MANAGED_TEXT_EXTENSIONS = new Set([
'.md', '.json', '.yml', '.yaml', '.js', '.mjs', '.cjs', '.ts',
'.ps1', '.sh', '.txt', '.template',
Expand Down Expand Up @@ -39,3 +44,109 @@ export function copyManagedFile(source: string, destination: string): void {
}
writeManagedText(destination, text);
}

/**
* Wrap raw Squad copilot-instructions content with markers if not already wrapped.
*/
function ensureCopilotInstructionsMarkers(templateContent: string): string {
const normalized = templateContent.replace(/\r\n?|\n/g, '\n').trim();
if (
normalized.startsWith(COPILOT_INSTRUCTIONS_START) &&
normalized.endsWith(COPILOT_INSTRUCTIONS_END)
) {
return normalized;
}
return `${COPILOT_INSTRUCTIONS_START}\n${normalized}\n${COPILOT_INSTRUCTIONS_END}`;
}

/**
* Merge the Squad-managed copilot-instructions block into existing file content,
* preserving 100% of user-authored instructions outside the managed block (#2186).
*/
export function mergeCopilotInstructionsContent(existing: string | undefined, templateContent: string): string {
const managedBlock = ensureCopilotInstructionsMarkers(templateContent);
if (existing === undefined || existing.trim().length === 0) {
return `${managedBlock}\n`;
}

const text = existing.replace(/\r\n?|\n/g, '\n');
const startIdx = text.indexOf(COPILOT_INSTRUCTIONS_START);
const endIdx = startIdx !== -1 ? text.indexOf(COPILOT_INSTRUCTIONS_END, startIdx) : -1;

if (startIdx !== -1 && endIdx !== -1) {
const before = text.slice(0, startIdx);
const after = text.slice(endIdx + COPILOT_INSTRUCTIONS_END.length);
return `${before}${managedBlock}${after}`;
}

// Legacy un-marked Squad instructions detection: replace only the legacy Squad block
const headingIdx = text.indexOf(LEGACY_COPILOT_INSTRUCTIONS_HEADING);
if (headingIdx !== -1) {
const tailIdx = text.indexOf(LEGACY_COPILOT_INSTRUCTIONS_TAIL, headingIdx);
if (tailIdx !== -1) {
let blockEnd = tailIdx + LEGACY_COPILOT_INSTRUCTIONS_TAIL.length;
if (text[blockEnd] === '\n') blockEnd++;
const before = text.slice(0, headingIdx);
const after = text.slice(blockEnd);
const suffix = after.length === 0 ? '\n' : after.startsWith('\n') ? after : `\n${after}`;
return `${before}${managedBlock}${suffix}`;
}
if (headingIdx === 0) {
return `${managedBlock}\n`;
}
}

// User-authored file without Squad markers: preserve user content and append Squad block
const trimmedEnd = text.replace(/\n+$/, '');
return `${trimmedEnd}\n\n${managedBlock}\n`;
}

/**
* Remove only the Squad-managed block from `.github/copilot-instructions.md`.
* Returns `null` when no user content remains (caller may delete the file),
* or the remaining user content string when user instructions exist.
*/
export function removeCopilotInstructionsBlock(existing: string): string | null {
const text = existing.replace(/\r\n?|\n/g, '\n');
const startIdx = text.indexOf(COPILOT_INSTRUCTIONS_START);
const endIdx = startIdx !== -1 ? text.indexOf(COPILOT_INSTRUCTIONS_END, startIdx) : -1;

if (startIdx !== -1 && endIdx !== -1) {
const before = text.slice(0, startIdx).replace(/\n+$/, '');
const after = text.slice(endIdx + COPILOT_INSTRUCTIONS_END.length).replace(/^\n+/, '');
const combined = [before, after].filter(s => s.trim().length > 0).join('\n\n');
return combined.trim().length > 0 ? `${combined.replace(/\n+$/, '')}\n` : null;
}

const headingIdx = text.indexOf(LEGACY_COPILOT_INSTRUCTIONS_HEADING);
if (headingIdx !== -1) {
const tailIdx = text.indexOf(LEGACY_COPILOT_INSTRUCTIONS_TAIL, headingIdx);
if (tailIdx !== -1) {
const before = text.slice(0, headingIdx).replace(/\n+$/, '');
const after = text.slice(tailIdx + LEGACY_COPILOT_INSTRUCTIONS_TAIL.length).replace(/^\n+/, '');
const combined = [before, after].filter(s => s.trim().length > 0).join('\n\n');
return combined.trim().length > 0 ? `${combined.replace(/\n+$/, '')}\n` : null;
}
if (headingIdx === 0) {
return null;
}
}

return existing;
}

/**
* Upsert the Squad-managed section in `.github/copilot-instructions.md` from the template file.
* Preserves destination line endings and all user-owned instructions.
*/
export function syncCopilotInstructionsFile(sourceTemplatePath: string, destinationPath: string): boolean {
const templateContent = readFileSync(sourceTemplatePath, 'utf8');
const existing = readExisting(destinationPath);
const nextContent = mergeCopilotInstructionsContent(existing, templateContent);
if (existing === undefined) {
writeFileSync(destinationPath, nextContent, 'utf8');
return true;
}
return writeManagedText(destinationPath, nextContent);
}

4 changes: 2 additions & 2 deletions packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { runMigrations } from './migrations.js';
import { scrubEmails } from './email-scrub.js';
import { getPackageVersion, stampVersion, readInstalledVersion } from './version.js';
import { projectSquadStateMcpSpec, resolveSquadStateMcpSpec, type SquadStateMcpSpec } from './mcp-spec.js';
import { copyManagedFile, writeManagedText } from './managed-file.js';
import { copyManagedFile, syncCopilotInstructionsFile, writeManagedText } from './managed-file.js';
export { resolveSquadStateMcpSpec } from './mcp-spec.js';
import { ensureSquadStateMcpInRoot, tombstoneStaleSquadStateInProjectMcp } from './mcp-root.js';

Expand Down Expand Up @@ -1370,7 +1370,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr

if (copilotEnabled && storage.existsSync(copilotInstructionsSrc)) {
storage.mkdirSync(path.dirname(copilotInstructionsDest), { recursive: true });
copyManagedFile(copilotInstructionsSrc, copilotInstructionsDest);
syncCopilotInstructionsFile(copilotInstructionsSrc, copilotInstructionsDest);
success('upgraded .github/copilot-instructions.md');
filesUpdated.push('copilot-instructions.md');
}
Expand Down
3 changes: 3 additions & 0 deletions packages/squad-cli/templates/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<!-- squad:copilot-instructions:start -->
# Copilot Coding Agent — Squad Instructions

You are working on a project that uses **Squad**, an AI team framework. When picking up issues autonomously, follow these guidelines.
Expand Down Expand Up @@ -62,3 +63,5 @@ If you make a decision that affects other team members, write it to:
.squad/decisions/inbox/copilot-{brief-slug}.md
```
The Scribe will merge it into the shared decisions file.
<!-- squad:copilot-instructions:end -->

3 changes: 3 additions & 0 deletions packages/squad-sdk/templates/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<!-- squad:copilot-instructions:start -->
# Copilot Coding Agent — Squad Instructions

You are working on a project that uses **Squad**, an AI team framework. When picking up issues autonomously, follow these guidelines.
Expand Down Expand Up @@ -62,3 +63,5 @@ If you make a decision that affects other team members, write it to:
.squad/decisions/inbox/copilot-{brief-slug}.md
```
The Scribe will merge it into the shared decisions file.
<!-- squad:copilot-instructions:end -->

3 changes: 3 additions & 0 deletions templates/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<!-- squad:copilot-instructions:start -->
# Copilot Coding Agent — Squad Instructions

You are working on a project that uses **Squad**, an AI team framework. When picking up issues autonomously, follow these guidelines.
Expand Down Expand Up @@ -62,3 +63,5 @@ If you make a decision that affects other team members, write it to:
.squad/decisions/inbox/copilot-{brief-slug}.md
```
The Scribe will merge it into the shared decisions file.
<!-- squad:copilot-instructions:end -->

5 changes: 5 additions & 0 deletions test/cli/upgrade-portability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ describe('upgrade portability and destination newlines', () => {
expect(body, file).not.toMatch(/(?<!\r)\n/);
if (version === 'current' && file === path.join('.github', 'copilot-instructions.md')) {
expect(body).toBe('OLD-MANAGED-BODY\r\nreplace this\r\n');
} else if (file === path.join('.github', 'copilot-instructions.md')) {
// #2186: user-authored content in copilot-instructions.md is preserved, and the Squad section is appended
expect(body, file).toContain('OLD-MANAGED-BODY\r\nreplace this\r\n');
expect(body, file).toContain('<!-- squad:copilot-instructions:start -->');
expect(body, file).toContain('<!-- squad:copilot-instructions:end -->');
} else {
expect(body, file).not.toContain('OLD-MANAGED-BODY');
}
Expand Down
80 changes: 80 additions & 0 deletions test/cli/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,4 +769,84 @@ describe('CLI: upgrade command', () => {
},
);
});

describe('.github/copilot-instructions.md user content preservation (#2186)', () => {
it('creates .github/copilot-instructions.md with Squad markers when missing and @copilot is enabled', async () => {
const teamPath = join(TEST_ROOT, '.squad', 'team.md');
writeFileSync(teamPath, readFileSync(teamPath, 'utf8') + '\n## Coding Agent\n\n🤖 Coding Agent\n');

const instructionsPath = join(TEST_ROOT, '.github', 'copilot-instructions.md');
if (existsSync(instructionsPath)) {
rmSync(instructionsPath, { force: true });
}

await runUpgrade(TEST_ROOT, { force: true });

expect(existsSync(instructionsPath)).toBe(true);
const content = readFileSync(instructionsPath, 'utf8');
expect(content).toContain('<!-- squad:copilot-instructions:start -->');
expect(content).toContain('# Copilot Coding Agent — Squad Instructions');
expect(content).toContain('<!-- squad:copilot-instructions:end -->');
});

it('preserves 100% of existing user instructions without markers and appends the Squad section cleanly', async () => {
const teamPath = join(TEST_ROOT, '.squad', 'team.md');
writeFileSync(teamPath, readFileSync(teamPath, 'utf8') + '\n## Coding Agent\n\n🤖 Coding Agent\n');

const instructionsPath = join(TEST_ROOT, '.github', 'copilot-instructions.md');
const userContent = [
'# My Project Copilot Instructions',
'',
'- Always use TypeScript strict mode.',
'- Follow repository naming conventions.',
'- Never use raw SQL strings in controllers.',
].join('\n');
writeFileSync(instructionsPath, userContent + '\n');

await runUpgrade(TEST_ROOT, { force: true });

const upgraded = readFileSync(instructionsPath, 'utf8');
expect(upgraded).toContain(userContent);
expect(upgraded.startsWith('# My Project Copilot Instructions')).toBe(true);
expect(upgraded).toContain('<!-- squad:copilot-instructions:start -->');
expect(upgraded).toContain('<!-- squad:copilot-instructions:end -->');
});

it('updates only the Squad section on subsequent upgrades without duplicating or clobbering user content', async () => {
const teamPath = join(TEST_ROOT, '.squad', 'team.md');
writeFileSync(teamPath, readFileSync(teamPath, 'utf8') + '\n## Coding Agent\n\n🤖 Coding Agent\n');

const instructionsPath = join(TEST_ROOT, '.github', 'copilot-instructions.md');
const userHeader = '# Custom User Header\n\nKeep these top instructions intact.';
const userFooter = '## Custom User Footer\n\nKeep these bottom instructions intact.';
const existingWithMarkers = [
userHeader,
'',
'<!-- squad:copilot-instructions:start -->',
'# Copilot Coding Agent — Squad Instructions',
'',
'Outdated Squad instructions from an older release.',
'<!-- squad:copilot-instructions:end -->',
'',
userFooter,
'',
].join('\n');
writeFileSync(instructionsPath, existingWithMarkers);

await runUpgrade(TEST_ROOT, { force: true });
const firstUpgrade = readFileSync(instructionsPath, 'utf8');

expect(firstUpgrade).toContain(userHeader);
expect(firstUpgrade).toContain(userFooter);
expect(firstUpgrade).not.toContain('Outdated Squad instructions from an older release.');
expect(firstUpgrade).toContain('## Coordinator Canary Check');
expect(firstUpgrade.match(/<!-- squad:copilot-instructions:start -->/g)?.length).toBe(1);
expect(firstUpgrade.match(/<!-- squad:copilot-instructions:end -->/g)?.length).toBe(1);

// Run a second upgrade to verify idempotence (no duplication)
await runUpgrade(TEST_ROOT, { force: true });
const secondUpgrade = readFileSync(instructionsPath, 'utf8');
expect(secondUpgrade).toBe(firstUpgrade);
});
});
});
Loading