diff --git a/CHANGELOG.md b/CHANGELOG.md
index 92b714e..106692a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### Breaking Changes
+
+- `codegraph-vba upgrade` no longer rewrites agent instruction files, MCP entries or the Claude prompt hook by default. Add `--refresh-agents` to refresh them. (#332)
+
+### Fixes
+
+- `install` leaves an agent instruction file alone when gentle-ai manages it, so the two CodeGraph guidance blocks no longer contradict each other. (#332)
+
## [2.1.1] - 2026-09-19
diff --git a/README.md b/README.md
index 5bb698f..794aa77 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,8 @@ irm https://raw.githubusercontent.com/ardelperal/codegraph-vba/main/install.ps1
**Upgrade any time** with `codegraph-vba upgrade` — it detects how you installed (bundle, npm, or npx) and updates in place. Add `--check` to see if an update is available, or `codegraph-vba upgrade ` to pin one.
+`upgrade` leaves agent instruction files, MCP entries and hooks alone. When gentle-ai manages CodeGraph guidance, the fork does not write a second block into those files. To refresh what a previous install wrote, run `codegraph-vba upgrade --refresh-agents`.
+
npm (legacy)
@@ -705,7 +707,7 @@ codegraph-vba impact # Analyze what code is affected by changin
codegraph-vba affected [files...] # Find test files affected by changes (see below)
codegraph-vba daemon # Manage background daemons — pick one to stop (alias: daemons)
codegraph-vba telemetry [on|off] # Show or change anonymous usage telemetry
-codegraph-vba upgrade [version] # Update to the latest release (--check, --force)
+codegraph-vba upgrade [version] # Update to the latest release (--check, --force, --refresh-agents)
codegraph-vba version # Print the installed version (also -v, --version)
codegraph-vba stats vba-rules [--json] # List every VBA extractor rule (concern, id, description, pattern, counts)
codegraph-vba help [command] # Show help, optionally for one command
diff --git a/__tests__/upgrade-agent-surfaces.test.ts b/__tests__/upgrade-agent-surfaces.test.ts
new file mode 100644
index 0000000..3e0d0a3
--- /dev/null
+++ b/__tests__/upgrade-agent-surfaces.test.ts
@@ -0,0 +1,260 @@
+/**
+ * Issue #332: `upgrade` must not rewrite agent surfaces (instruction files,
+ * MCP entries, hooks) by default, and `install` must leave instruction files
+ * that gentle-ai manages alone.
+ *
+ * Every test runs against a fixture HOME in a temp dir. HOME, USERPROFILE,
+ * APPDATA and XDG_CONFIG_HOME are overridden, so no real agent config is
+ * ever read or written.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { ALL_TARGETS, getTarget } from '../src/installer/targets/registry';
+import { refreshTargets } from '../src/installer';
+import { runUpgrade, type UpgradeDeps } from '../src/upgrade';
+
+const GENTLE_AI_BLOCK = [
+ '',
+ '## CodeGraph',
+ 'Initialise CodeGraph immediately in every repository.',
+ '',
+].join('\n');
+
+const ENV_KEYS = [
+ 'HOME',
+ 'USERPROFILE',
+ 'APPDATA',
+ 'XDG_CONFIG_HOME',
+ 'HERMES_HOME',
+ 'CODEGRAPH_NO_INSTALL_REFRESH',
+ 'CODEGRAPH_NO_PROMPT_HOOK',
+ 'CODEGRAPH_PROMPT_HOOK',
+] as const;
+
+/** Map of every file under `dir` (relative path → content). */
+function snapshot(dir: string): Record {
+ const out: Record = {};
+ const walk = (d: string): void => {
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
+ const p = path.join(d, entry.name);
+ if (entry.isDirectory()) walk(p);
+ else out[path.relative(dir, p)] = fs.readFileSync(p, 'utf-8');
+ }
+ };
+ walk(dir);
+ return out;
+}
+
+function fakeDeps(over: Partial = {}): {
+ deps: UpgradeDeps;
+ calls: Array<{ cmd: string; args: string[] }>;
+ logs: string[];
+} {
+ const calls: Array<{ cmd: string; args: string[] }> = [];
+ const logs: string[] = [];
+ const deps: UpgradeDeps = {
+ currentVersion: '1.0.0',
+ method: { kind: 'npm', scope: 'global' },
+ resolveLatest: async () => '2.0.0',
+ run: (cmd, args) => {
+ calls.push({ cmd, args });
+ return 0;
+ },
+ capture: () => null,
+ hasCommand: () => true,
+ log: (m) => logs.push(m),
+ warn: (m) => logs.push(m),
+ error: (m) => logs.push(m),
+ platform: 'linux',
+ installedPackageVersion: '2.0.0',
+ orphanCleanupOverride: () => 0,
+ ...over,
+ };
+ return { deps, calls, logs };
+}
+
+describe('Issue #332 — fixture home with a gentle-ai-managed CLAUDE.md', () => {
+ let tmpHome: string;
+ let tmpCwd: string;
+ let origCwd: string;
+ let savedEnv: Record;
+
+ beforeEach(() => {
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-332-home-'));
+ tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-332-cwd-'));
+ origCwd = process.cwd();
+ savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
+ for (const k of ENV_KEYS) delete process.env[k];
+ process.env.HOME = tmpHome;
+ process.env.USERPROFILE = tmpHome;
+ process.env.APPDATA = path.join(tmpHome, '.config');
+ process.env.XDG_CONFIG_HOME = path.join(tmpHome, '.config');
+ process.chdir(tmpCwd);
+
+ // Claude Code is configured (MCP entry present) and gentle-ai owns its
+ // CLAUDE.md. Codex is configured too, with a plain AGENTS.md that has no
+ // gentle-ai marker: it must keep receiving the CodeGraph block.
+ fs.writeFileSync(
+ path.join(tmpHome, '.claude.json'),
+ JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2),
+ );
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
+ fs.writeFileSync(path.join(tmpHome, '.claude', 'CLAUDE.md'), `# Mine\n\n${GENTLE_AI_BLOCK}\n`);
+ fs.mkdirSync(path.join(tmpHome, '.codex'), { recursive: true });
+ fs.writeFileSync(
+ path.join(tmpHome, '.codex', 'config.toml'),
+ '[mcp_servers.codegraph]\ncommand = "codegraph"\nargs = ["serve", "--mcp"]\n',
+ );
+ fs.writeFileSync(path.join(tmpHome, '.codex', 'AGENTS.md'), '# Codex notes\n');
+ });
+
+ afterEach(() => {
+ process.chdir(origCwd);
+ for (const k of ENV_KEYS) {
+ if (savedEnv[k] === undefined) delete process.env[k];
+ else process.env[k] = savedEnv[k];
+ }
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+ fs.rmSync(tmpCwd, { recursive: true, force: true });
+ });
+
+ describe('upgrade without --refresh-agents', () => {
+ it('leaves every agent file byte-identical and runs no refresh or hook write', async () => {
+ const before = snapshot(tmpHome);
+ const { deps, calls } = fakeDeps();
+
+ const code = await runUpgrade({}, deps);
+
+ expect(code).toBe(0);
+ expect(snapshot(tmpHome)).toEqual(before);
+ expect(calls.some((c) => c.args.includes('--refresh'))).toBe(false);
+ expect(fs.existsSync(path.join(tmpHome, '.claude', 'settings.json'))).toBe(false);
+ });
+
+ it('summary names the agent surfaces it did not touch and the opt-in flag', async () => {
+ const { deps, logs } = fakeDeps();
+
+ await runUpgrade({}, deps);
+
+ const summary = logs.join('\n');
+ expect(summary).toMatch(/--refresh-agents/);
+ expect(summary).toMatch(/instruction files/);
+ expect(summary).toMatch(/MCP entries/);
+ expect(summary).toMatch(/prompt hook/);
+ });
+
+ it('CODEGRAPH_NO_INSTALL_REFRESH and CODEGRAPH_NO_PROMPT_HOOK do not matter when not opted in', async () => {
+ // Kill-switches only narrow the explicit opt-in; the default path already
+ // skips every surface, so setting them must not change the result.
+ process.env.CODEGRAPH_NO_INSTALL_REFRESH = '1';
+ const before = snapshot(tmpHome);
+ const { deps, calls } = fakeDeps();
+
+ await runUpgrade({}, deps);
+
+ expect(calls.some((c) => c.args.includes('--refresh'))).toBe(false);
+ expect(snapshot(tmpHome)).toEqual(before);
+ });
+ });
+
+ describe('upgrade with the explicit --refresh-agents opt-in', () => {
+ it('still refreshes configured agents via `codegraph install --refresh`', async () => {
+ const { deps, calls } = fakeDeps();
+
+ const code = await runUpgrade({ refreshAgents: true }, deps);
+
+ expect(code).toBe(0);
+ expect(calls).toContainEqual({ cmd: 'codegraph', args: ['install', '--refresh'] });
+ });
+
+ it('still wires the Claude prompt hook for a configured Claude install', async () => {
+ const { deps } = fakeDeps();
+
+ await runUpgrade({ refreshAgents: true }, deps);
+
+ expect(fs.existsSync(path.join(tmpHome, '.claude', 'settings.json'))).toBe(true);
+ });
+
+ it('CODEGRAPH_NO_INSTALL_REFRESH=1 still disables the refresh under the opt-in', async () => {
+ process.env.CODEGRAPH_NO_INSTALL_REFRESH = '1';
+ const { deps, calls } = fakeDeps();
+
+ await runUpgrade({ refreshAgents: true }, deps);
+
+ expect(calls.some((c) => c.args.includes('--refresh'))).toBe(false);
+ });
+
+ it('CODEGRAPH_NO_PROMPT_HOOK=1 still disables the prompt hook under the opt-in', async () => {
+ process.env.CODEGRAPH_NO_PROMPT_HOOK = '1';
+ const { deps } = fakeDeps();
+
+ await runUpgrade({ refreshAgents: true }, deps);
+
+ expect(fs.existsSync(path.join(tmpHome, '.claude', 'settings.json'))).toBe(false);
+ });
+ });
+
+ describe('install and install --refresh with a gentle-ai-managed file', () => {
+ it('refreshTargets adds no CODEGRAPH_START block to the managed file, reports it skipped, and still updates other files', () => {
+ const reports = refreshTargets(ALL_TARGETS, 'global');
+
+ const claudeText = fs.readFileSync(path.join(tmpHome, '.claude', 'CLAUDE.md'), 'utf-8');
+ expect(claudeText).toBe(`# Mine\n\n${GENTLE_AI_BLOCK}\n`);
+ expect(claudeText).not.toContain('CODEGRAPH_START');
+
+ const claude = reports.find((r) => r.id === 'claude');
+ expect(claude?.skippedPaths).toContain(path.join(tmpHome, '.claude', 'CLAUDE.md'));
+
+ const codexAgents = fs.readFileSync(path.join(tmpHome, '.codex', 'AGENTS.md'), 'utf-8');
+ expect(codexAgents).toContain('');
+ });
+
+ it('claude install (not refresh) reports CLAUDE.md as skipped and leaves it unchanged', () => {
+ const claude = getTarget('claude');
+ if (!claude) throw new Error('claude target missing');
+ const before = fs.readFileSync(path.join(tmpHome, '.claude', 'CLAUDE.md'), 'utf-8');
+
+ const result = claude.install('global', { autoAllow: false });
+
+ expect(result.files).toContainEqual({
+ path: path.join(tmpHome, '.claude', 'CLAUDE.md'),
+ action: 'skipped',
+ });
+ expect(fs.readFileSync(path.join(tmpHome, '.claude', 'CLAUDE.md'), 'utf-8')).toBe(before);
+ });
+
+ it('upgrade (default and --refresh-agents), install and install --refresh never change skills directories', async () => {
+ // Skills reach projects through the fleet propagator, never through
+ // tool installers (issue #332 scope note). Pin mtimes to a past date so
+ // any rewrite, even with identical bytes, shows up as a change.
+ const past = new Date('2020-01-01T00:00:00Z');
+ const sentinels = [
+ path.join(tmpHome, '.claude', 'skills', 'x', 'SKILL.md'),
+ path.join(tmpHome, '.agents', 'skills', 'x', 'SKILL.md'),
+ path.join(tmpHome, '.codex', 'skills', 'x', 'SKILL.md'),
+ path.join(tmpHome, '.config', 'opencode', 'skills', 'x', 'SKILL.md'),
+ ];
+ for (const f of sentinels) {
+ fs.mkdirSync(path.dirname(f), { recursive: true });
+ fs.writeFileSync(f, `# sentinel ${path.relative(tmpHome, f)}\n`);
+ fs.utimesSync(f, past, past);
+ }
+ const state = () =>
+ sentinels.map((f) => ({ f, bytes: fs.readFileSync(f, 'utf-8'), mtimeMs: fs.statSync(f).mtimeMs }));
+ const before = state();
+
+ await runUpgrade({}, fakeDeps().deps);
+ await runUpgrade({ refreshAgents: true }, fakeDeps().deps);
+ for (const target of ALL_TARGETS) {
+ target.install('global', { autoAllow: true, promptHook: true });
+ }
+ refreshTargets(ALL_TARGETS, 'global');
+
+ expect(state()).toEqual(before);
+ for (const f of sentinels) expect(fs.existsSync(f)).toBe(true);
+ });
+ });
+});
diff --git a/__tests__/upgrade.test.ts b/__tests__/upgrade.test.ts
index 7942c93..2577c04 100644
--- a/__tests__/upgrade.test.ts
+++ b/__tests__/upgrade.test.ts
@@ -497,7 +497,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
currentVersion: '0.9.8',
hasCommand: (cmd) => cmd === 'codegraph',
});
- const code = await runUpgrade({}, deps);
+ const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
// The refresh is spawned AFTER the binary swap, so the fresh install
// (with the current templates) does the writing — not this process.
@@ -513,7 +513,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
platform: 'win32',
hasCommand: (cmd) => cmd === 'codegraph',
});
- const code = await runUpgrade({}, deps);
+ const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
const last = calls.runs[calls.runs.length - 1];
expect(last?.cmd).toBe('cmd.exe');
@@ -526,7 +526,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
currentVersion: '0.9.8',
// default hasCommand resolves only curl
});
- const code = await runUpgrade({}, deps);
+ const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
});
@@ -541,7 +541,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
calls.runs.push({ cmd, args, env });
return cmd === 'codegraph' ? 1 : 0;
};
- const code = await runUpgrade({}, deps);
+ const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
expect(calls.logs.join('\n')).toMatch(/install --refresh/);
});
@@ -568,7 +568,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
currentVersion: '0.9.8',
hasCommand: (cmd) => cmd === 'codegraph',
});
- const code = await runUpgrade({}, deps);
+ const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
} finally {
@@ -583,7 +583,7 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
hasCommand: (cmd) => cmd === 'codegraph',
capture: () => ({ code: 0, stdout: '0.9.8\n' }), // PATH still serves the OLD version
});
- const code = await runUpgrade({}, deps);
+ const code = await runUpgrade({ refreshAgents: true }, deps);
expect(code).toBe(0);
// Spawning `codegraph install --refresh` would execute the shadowed stale
// binary — the exact staleness the refresh exists to heal.
diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts
index 6b7a49b..0cf1cae 100644
--- a/src/bin/codegraph.ts
+++ b/src/bin/codegraph.ts
@@ -2213,7 +2213,7 @@ program
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
.option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code only)')
.option('--print-config ', 'Print MCP config snippet for the named agent and exit (no file writes)')
- .option('--refresh', 'Rewrite what previous installs configured, for already-configured agents only (never adds new ones). Run automatically by `codegraph upgrade`')
+ .option('--refresh', 'Rewrite what previous installs configured, for already-configured agents only (never adds new ones). Run by `codegraph upgrade --refresh-agents`')
.action(async (opts: {
target?: string;
location?: string;
@@ -2258,6 +2258,9 @@ program
changed += 1;
console.log(` ${report.displayName}: refreshed ${p}`);
}
+ for (const p of report.skippedPaths) {
+ console.log(` ${report.displayName}: skipped ${p} (managed by gentle-ai)`);
+ }
}
}
if (changed === 0) {
@@ -2393,7 +2396,8 @@ program
.description('Update CodeGraph to the latest release (or a specific version)')
.option('--check', 'Check whether an update is available without installing')
.option('-f, --force', 'Reinstall even if already on the target version')
- .action(async (versionArg: string | undefined, options: { check?: boolean; force?: boolean }) => {
+ .option('--refresh-agents', 'Also refresh agent instruction files, MCP entries and the Claude prompt hook (off by default; gentle-ai usually manages them)')
+ .action(async (versionArg: string | undefined, options: { check?: boolean; force?: boolean; refreshAgents?: boolean }) => {
const up = await import('../upgrade');
const method = up.detectInstallMethod({
filename: __filename,
@@ -2402,7 +2406,7 @@ program
});
const pin = versionArg || process.env.CODEGRAPH_VERSION || undefined;
const code = await up.runUpgrade(
- { version: pin, check: options.check, force: options.force },
+ { version: pin, check: options.check, force: options.force, refreshAgents: options.refreshAgents },
{
currentVersion: packageJson.version,
method,
diff --git a/src/installer/index.ts b/src/installer/index.ts
index 41a09b7..20edd47 100644
--- a/src/installer/index.ts
+++ b/src/installer/index.ts
@@ -383,6 +383,8 @@ export interface RefreshReport {
status: RefreshStatus;
/** Absolute paths created, updated, or removed by the refresh. */
changedPaths: string[];
+ /** Absolute paths left alone because gentle-ai manages them (issue #332). */
+ skippedPaths: string[];
}
/**
@@ -411,19 +413,21 @@ export function refreshTargets(
return targets.map((target) => {
const base = { id: target.id, displayName: target.displayName, location };
if (!target.supportsLocation(location)) {
- return { ...base, status: 'unsupported' as const, changedPaths: [] };
+ return { ...base, status: 'unsupported' as const, changedPaths: [], skippedPaths: [] };
}
if (!target.detect(location).alreadyConfigured) {
- return { ...base, status: 'not-configured' as const, changedPaths: [] };
+ return { ...base, status: 'not-configured' as const, changedPaths: [], skippedPaths: [] };
}
const result = target.install(location, { autoAllow: false, promptHook: undefined });
const changedPaths = result.files
.filter((f) => f.action === 'created' || f.action === 'updated' || f.action === 'removed')
.map((f) => f.path);
+ const skippedPaths = result.files.filter((f) => f.action === 'skipped').map((f) => f.path);
return {
...base,
status: changedPaths.length > 0 ? ('refreshed' as const) : ('unchanged' as const),
changedPaths,
+ skippedPaths,
};
});
}
diff --git a/src/installer/targets/shared.ts b/src/installer/targets/shared.ts
index ba3b07e..a3c7e29 100644
--- a/src/installer/targets/shared.ts
+++ b/src/installer/targets/shared.ts
@@ -176,6 +176,9 @@ export function replaceOrAppendMarkedSection(
return 'appended';
}
+/** Marker prefix gentle-ai writes around the CodeGraph guidance it manages. */
+export const GENTLE_AI_MARKER_PREFIX = '