From 0e3339299f5045c91cad768c756bc3afcbf6388a Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 14 Oct 2026 17:17:23 +0300 Subject: [PATCH] fix(cli): preserve user .github/copilot-instructions.md during squad upgrade (#2186) Manage Squad instructions inside .github/copilot-instructions.md using and markers instead of overwriting the entire file. Closes #2186 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cf0811c-a02a-41a7-ab00-904b6900db7b --- ...reserve-copilot-instructions-on-upgrade.md | 5 + .squad-templates/copilot-instructions.md | 3 + .../squad-cli/src/cli/commands/copilot.ts | 18 ++- packages/squad-cli/src/cli/core/init.ts | 5 +- .../squad-cli/src/cli/core/managed-file.ts | 113 +++++++++++++++++- packages/squad-cli/src/cli/core/upgrade.ts | 4 +- .../templates/copilot-instructions.md | 3 + .../templates/copilot-instructions.md | 3 + templates/copilot-instructions.md | 3 + test/cli/upgrade-portability.test.ts | 5 + test/cli/upgrade.test.ts | 80 +++++++++++++ 11 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 .changeset/preserve-copilot-instructions-on-upgrade.md diff --git a/.changeset/preserve-copilot-instructions-on-upgrade.md b/.changeset/preserve-copilot-instructions-on-upgrade.md new file mode 100644 index 000000000..9ec87a4ab --- /dev/null +++ b/.changeset/preserve-copilot-instructions-on-upgrade.md @@ -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 `` and `` markers instead of overwriting the entire file (#2186). diff --git a/.squad-templates/copilot-instructions.md b/.squad-templates/copilot-instructions.md index e5bab9f71..192d2782d 100644 --- a/.squad-templates/copilot-instructions.md +++ b/.squad-templates/copilot-instructions.md @@ -1,3 +1,4 @@ + # 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. @@ -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. + + diff --git a/packages/squad-cli/src/cli/commands/copilot.ts b/packages/squad-cli/src/cli/commands/copilot.ts index e974a0240..f863b7cb8 100644 --- a/packages/squad-cli/src/cli/commands/copilot.ts +++ b/packages/squad-cli/src/cli/commands/copilot.ts @@ -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, @@ -50,11 +51,18 @@ export async function runCopilot(dest: string, flags: CopilotFlags): Promise 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); +} + diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index b3e38b2bb..8d96e7079 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -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'; @@ -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'); } diff --git a/packages/squad-cli/templates/copilot-instructions.md b/packages/squad-cli/templates/copilot-instructions.md index e5bab9f71..192d2782d 100644 --- a/packages/squad-cli/templates/copilot-instructions.md +++ b/packages/squad-cli/templates/copilot-instructions.md @@ -1,3 +1,4 @@ + # 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. @@ -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. + + diff --git a/packages/squad-sdk/templates/copilot-instructions.md b/packages/squad-sdk/templates/copilot-instructions.md index e5bab9f71..192d2782d 100644 --- a/packages/squad-sdk/templates/copilot-instructions.md +++ b/packages/squad-sdk/templates/copilot-instructions.md @@ -1,3 +1,4 @@ + # 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. @@ -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. + + diff --git a/templates/copilot-instructions.md b/templates/copilot-instructions.md index e5bab9f71..192d2782d 100644 --- a/templates/copilot-instructions.md +++ b/templates/copilot-instructions.md @@ -1,3 +1,4 @@ + # 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. @@ -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. + + diff --git a/test/cli/upgrade-portability.test.ts b/test/cli/upgrade-portability.test.ts index dcfe482d1..b189a153d 100644 --- a/test/cli/upgrade-portability.test.ts +++ b/test/cli/upgrade-portability.test.ts @@ -98,6 +98,11 @@ describe('upgrade portability and destination newlines', () => { expect(body, file).not.toMatch(/(?'); + expect(body, file).toContain(''); } else { expect(body, file).not.toContain('OLD-MANAGED-BODY'); } diff --git a/test/cli/upgrade.test.ts b/test/cli/upgrade.test.ts index 726908451..155d5ce41 100644 --- a/test/cli/upgrade.test.ts +++ b/test/cli/upgrade.test.ts @@ -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(''); + expect(content).toContain('# Copilot Coding Agent — Squad Instructions'); + expect(content).toContain(''); + }); + + 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(''); + expect(upgraded).toContain(''); + }); + + 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, + '', + '', + '# Copilot Coding Agent — Squad Instructions', + '', + 'Outdated Squad instructions from an older release.', + '', + '', + 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(//g)?.length).toBe(1); + expect(firstUpgrade.match(//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); + }); + }); });