From da214d73b70fa9494a4ccc0784159fd29924116f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 18 Sep 2026 18:37:05 -0400 Subject: [PATCH 1/2] fix: stop home installs copying .agents into ~/.claude and ~/.codex --- scripts/lib/install-targets/claude-home.js | 4 +- scripts/lib/install-targets/codex-home.js | 3 +- scripts/lib/install-targets/helpers.js | 25 ++++++++++- tests/scripts/install-apply.test.js | 48 ++++++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/scripts/lib/install-targets/claude-home.js b/scripts/lib/install-targets/claude-home.js index 5cc426ac9..2de35ec58 100644 --- a/scripts/lib/install-targets/claude-home.js +++ b/scripts/lib/install-targets/claude-home.js @@ -1,6 +1,7 @@ const path = require('path'); const { + HOME_INSTALL_EXCLUDED_SOURCE_PATHS, createInstallTargetAdapter, createRemappedOperation, isForeignPlatformPath, @@ -52,6 +53,7 @@ module.exports = createInstallTargetAdapter({ kind: 'home', rootSegments: ['.claude'], installStatePathSegments: ['ecc', 'install-state.json'], + excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS, nativeRootRelativePath: '.claude-plugin', planOperations(input, adapter) { const modules = Array.isArray(input.modules) @@ -66,7 +68,7 @@ module.exports = createInstallTargetAdapter({ return modules.flatMap(module => { const paths = Array.isArray(module.paths) ? module.paths : []; return paths - .filter(p => !isForeignPlatformPath(p, adapter.target)) + .filter(p => !isForeignPlatformPath(p, adapter.target) && !adapter.excludesSourcePath(p)) .flatMap(sourceRelativePath => { if ( module.id === 'hooks-runtime' diff --git a/scripts/lib/install-targets/codex-home.js b/scripts/lib/install-targets/codex-home.js index ae29b41a1..aff32c4c5 100644 --- a/scripts/lib/install-targets/codex-home.js +++ b/scripts/lib/install-targets/codex-home.js @@ -1,4 +1,4 @@ -const { createInstallTargetAdapter } = require('./helpers'); +const { HOME_INSTALL_EXCLUDED_SOURCE_PATHS, createInstallTargetAdapter } = require('./helpers'); module.exports = createInstallTargetAdapter({ id: 'codex-home', @@ -7,4 +7,5 @@ module.exports = createInstallTargetAdapter({ rootSegments: ['.codex'], installStatePathSegments: ['ecc-install-state.json'], nativeRootRelativePath: '.codex', + excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS, }); diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index f69d75e86..5df5ae1c4 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -24,6 +24,14 @@ const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({ '.adal': 'adal', }); +// Source paths that home installs must never copy into a harness home +// directory. `.agents` is ECC's repo-local skills/plugins staging area: +// project targets such as kimi and antigravity consume it, but neither +// Claude Code nor Codex reads a `.agents` directory under ~/.claude or +// ~/.codex, so copying it there produces unread files that doctor flags as +// drift and repair keeps restoring. +const HOME_INSTALL_EXCLUDED_SOURCE_PATHS = Object.freeze(['.agents']); + function normalizeRelativePath(relativePath) { return String(relativePath || '') .replace(/\\/g, '/') @@ -43,6 +51,14 @@ function isForeignPlatformPath(sourceRelativePath, adapterTarget) { return false; } +function isExcludedSourcePath(sourceRelativePath, excludedSourcePaths = []) { + const normalizedPath = normalizeRelativePath(sourceRelativePath); + return excludedSourcePaths.some(excluded => { + const prefix = normalizeRelativePath(excluded); + return prefix !== '' && (normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`)); + }); +} + function resolveBaseRoot(scope, input = {}) { if (scope === 'home') { return input.homeDir || os.homedir(); @@ -351,6 +367,9 @@ function createInstallTargetAdapter(config) { strategy: adapter.determineStrategy(normalizedSourcePath), }); }, + excludesSourcePath(sourceRelativePath) { + return isExcludedSourcePath(sourceRelativePath, config.excludedSourcePaths); + }, planOperations(input = {}) { if (typeof config.planOperations === 'function') { return config.planOperations(input, adapter); @@ -360,7 +379,7 @@ function createInstallTargetAdapter(config) { return input.modules.flatMap(module => { const paths = Array.isArray(module.paths) ? module.paths : []; return paths - .filter(p => !isForeignPlatformPath(p, config.target)) + .filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p)) .map(sourceRelativePath => adapter.createScaffoldOperation( module.id, sourceRelativePath, @@ -372,7 +391,7 @@ function createInstallTargetAdapter(config) { const module = input.module || {}; const paths = Array.isArray(module.paths) ? module.paths : []; return paths - .filter(p => !isForeignPlatformPath(p, config.target)) + .filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p)) .map(sourceRelativePath => adapter.createScaffoldOperation( module.id, sourceRelativePath, @@ -399,6 +418,8 @@ function createInstallTargetAdapter(config) { } module.exports = { + HOME_INSTALL_EXCLUDED_SOURCE_PATHS, + isExcludedSourcePath, buildValidationIssue, createFlatFileOperations, createFlatRuleOperations, diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 270339cb9..c1e935dcc 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -593,6 +593,54 @@ function runTests() { } })) passed++; else failed++; + if (test('home installs do not copy the repo .agents staging directory into Claude or Codex homes', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const claudeResult = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); + assert.strictEqual(claudeResult.code, 0, claudeResult.stderr); + + const claudeRoot = path.join(homeDir, '.claude'); + assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); + assert.ok( + !fs.existsSync(path.join(claudeRoot, '.agents')), + 'Claude home must not receive the repo .agents staging directory' + ); + + const claudeState = readJson(path.join(claudeRoot, 'ecc', 'install-state.json')); + assert.ok( + !claudeState.operations.some(operation => ( + String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents' + )), + 'Claude install-state must not record .agents copy operations' + ); + + const codexResult = run(['--target', 'codex', '--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(codexResult.code, 0, codexResult.stderr); + + const codexRoot = path.join(homeDir, '.codex'); + assert.ok(fs.existsSync(path.join(codexRoot, 'agents', 'architect.md'))); + assert.ok(fs.existsSync(path.join(codexRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); + assert.ok( + !fs.existsSync(path.join(codexRoot, '.agents')), + 'Codex home must not receive the repo .agents staging directory' + ); + + const codexState = readJson(path.join(codexRoot, 'ecc-install-state.json')); + assert.ok( + !codexState.operations.some(operation => ( + String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents' + )), + 'Codex install-state must not record .agents copy operations' + ); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('preserves existing top-level Claude rules and skills during managed install', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); From 8cfbc26797ca94d90b866fca1878fe548e9fd6bd Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 18 Sep 2026 20:13:11 -0400 Subject: [PATCH 2/2] fix: reconcile pre-exclusion .agents installs on claude and codex home upgrades --- scripts/install-apply.js | 7 + scripts/lib/install/apply.js | 25 +- .../install/excluded-paths-reconciliation.js | 230 ++++++++++++++++++ tests/scripts/install-apply.test.js | 129 ++++++++++ 4 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/install/excluded-paths-reconciliation.js diff --git a/scripts/install-apply.js b/scripts/install-apply.js index 1435d2ff6..722f7d6b6 100755 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -132,6 +132,13 @@ function printHumanPlan(plan, dryRun) { } } + if (Array.isArray(plan.reconciledExcludedPaths) && plan.reconciledExcludedPaths.length > 0) { + console.log('\nReconciled excluded paths:'); + for (const removedPath of plan.reconciledExcludedPaths) { + console.log(`- removed ${removedPath}`); + } + } + if (!dryRun) { console.log(`\nDone. Install-state written to ${plan.installStatePath}`); } diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index fbab1293b..bd0f41fd5 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -34,6 +34,10 @@ const { preserveUnwrittenFiles, } = require('./ownership-guard'); const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration'); +const { + completeExcludedPathsReconciliation, + prepareExcludedPathsReconciliation, +} = require('./excluded-paths-reconciliation'); const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite'); const { adaptAntigravityAgent } = require('./antigravity-agent'); @@ -449,9 +453,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals if (typeof beforeInstallStateRead === 'function') { beforeInstallStateRead({ plan }); } - const migration = prepareHookConsentMigration( + const migration = prepareExcludedPathsReconciliation( plan, - prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + prepareHookConsentMigration( + plan, + prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + ) ); const appliedPlan = { ...plan, @@ -666,17 +673,31 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals ]; } + let excludedPathsRemoved = []; + let excludedPathsWarnings = []; + try { + const excludedReconciliation = completeExcludedPathsReconciliation(migration, appliedPlan); + excludedPathsRemoved = excludedReconciliation.removedPaths; + excludedPathsWarnings = excludedReconciliation.warnings; + } catch (error) { + excludedPathsWarnings = [ + `Excluded-paths reconciliation did not finish: ${error.message}. Previously managed files under excluded source paths were preserved; remove them manually or rerun the install.`, + ]; + } + return { ...plan, statePreview: finalState, plannedOperations: [...plan.operations], operations: migration.appliedOperations, skippedOperations: migration.skippedOperations, + reconciledExcludedPaths: excludedPathsRemoved, warnings: [ ...(Array.isArray(plan.warnings) ? plan.warnings : []), ...migration.warnings, ...antigravityMigrationWarnings, ...opencodeMigrationWarnings, + ...excludedPathsWarnings, ], applied: true, }; diff --git a/scripts/lib/install/excluded-paths-reconciliation.js b/scripts/lib/install/excluded-paths-reconciliation.js new file mode 100644 index 000000000..845dfd400 --- /dev/null +++ b/scripts/lib/install/excluded-paths-reconciliation.js @@ -0,0 +1,230 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const { readInstallState } = require('../install-state'); +const { assertWithinTrustedRoot } = require('../path-safety'); +const { getInstallTargetAdapter } = require('../install-targets/registry'); + +/** + * Upgrade reconciliation for excluded source paths (issue #3116). + * + * Adapters can declare `excludedSourcePaths` (today: `.agents` for the Claude + * and Codex home targets). The exclusion stops new copy operations from being + * planned, but a home install created before the exclusion still has the + * copied files on disk and the copy operations recorded in install-state, so + * doctor keeps reporting drift and repair keeps restoring files the target + * never reads. + * + * prepareExcludedPathsReconciliation runs before the new state is written: it + * reads the previous install-state and drops the recorded managed operations + * whose source path is now excluded. completeExcludedPathsReconciliation runs + * after a successful apply: it removes the files those operations recorded, + * but only when the recorded content digest still matches, and prunes the + * emptied directories. Files the state does not own, modified files, + * symlinks, and anything outside the target root are preserved with a + * warning. + */ + +function comparablePath(filePath) { + const resolvedPath = path.resolve(filePath); + return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath; +} + +function getReconcilingAdapter(plan) { + if (!plan || typeof plan.target !== 'string') { + return null; + } + let adapter; + try { + adapter = getInstallTargetAdapter(plan.target); + } catch { + return null; + } + return adapter && typeof adapter.excludesSourcePath === 'function' ? adapter : null; +} + +function isRecordedExcludedManagedOperation(adapter, operation) { + return Boolean( + operation + && operation.ownership === 'managed' + && typeof operation.destinationPath === 'string' + && typeof operation.sourceRelativePath === 'string' + && adapter.excludesSourcePath(operation.sourceRelativePath) + ); +} + +function filterStateOperations(state, shouldDrop) { + if (!state || !Array.isArray(state.operations)) { + return state; + } + return { + ...state, + operations: state.operations.filter(operation => !shouldDrop(operation)), + }; +} + +function prepareExcludedPathsReconciliation(plan, migration) { + const adapter = getReconcilingAdapter(plan); + if (!adapter || !fs.existsSync(plan.installStatePath)) { + return { ...migration, excludedPathCandidates: [] }; + } + + const previousState = readInstallState(plan.installStatePath); + const candidates = ((previousState && previousState.operations) || []) + .filter(operation => isRecordedExcludedManagedOperation(adapter, operation)); + + if (candidates.length === 0) { + return { ...migration, excludedPathCandidates: [] }; + } + + const droppedDestinations = new Set( + candidates.map(operation => comparablePath(operation.destinationPath)) + ); + const shouldDrop = operation => Boolean( + operation + && typeof operation.destinationPath === 'string' + && droppedDestinations.has(comparablePath(operation.destinationPath)) + && typeof operation.sourceRelativePath === 'string' + && adapter.excludesSourcePath(operation.sourceRelativePath) + ); + + return { + ...migration, + bridgeState: filterStateOperations(migration.bridgeState, shouldDrop), + finalState: filterStateOperations(migration.finalState, shouldDrop), + excludedPathCandidates: candidates, + }; +} + +function pathExists(filePath) { + try { + fs.lstatSync(filePath); + return true; + } catch (error) { + if (error && error.code === 'ENOENT') { + return false; + } + throw error; + } +} + +function hashFileNoFollow(filePath) { + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + const descriptor = fs.openSync(filePath, flags); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if (!before.isFile()) { + throw new Error(`Refusing to read a non-file at ${filePath}`); + } + const content = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + const finalPathStat = fs.lstatSync(filePath, { bigint: true }); + const unchanged = before.dev === after.dev + && before.ino === after.ino + && before.size === after.size + && after.dev === finalPathStat.dev + && after.ino === finalPathStat.ino + && after.size === finalPathStat.size; + if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) { + throw new Error(`Refusing to read a file that changed during validation: ${filePath}`); + } + return crypto.createHash('sha256').update(content).digest('hex'); + } finally { + fs.closeSync(descriptor); + } +} + +function removeEmptyParents(startPath, targetRoot) { + let currentPath = path.dirname(startPath); + while (comparablePath(currentPath) !== comparablePath(targetRoot)) { + const safePath = assertWithinTrustedRoot( + currentPath, + targetRoot, + 'reconcile excluded install paths' + ); + if (!pathExists(safePath)) { + currentPath = path.dirname(safePath); + continue; + } + const stat = fs.lstatSync(safePath); + if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) { + return; + } + fs.rmdirSync(safePath); + currentPath = path.dirname(safePath); + } +} + +function completeExcludedPathsReconciliation(migration, plan) { + const candidates = (migration && migration.excludedPathCandidates) || []; + const removedPaths = []; + const warnings = []; + + for (const candidate of candidates) { + if (candidate.kind !== 'copy-file') { + continue; + } + + let safePath; + try { + safePath = assertWithinTrustedRoot( + candidate.destinationPath, + plan.targetRoot, + 'reconcile excluded install paths' + ); + } catch (error) { + warnings.push( + `Preserved previously managed file ${candidate.destinationPath}: ${error.message}` + ); + continue; + } + + if (!pathExists(safePath)) { + continue; + } + + const stat = fs.lstatSync(safePath); + if (stat.isSymbolicLink() || !stat.isFile()) { + warnings.push( + `Preserved previously managed file ${safePath}: it is not a regular file; remove it manually if unwanted.` + ); + continue; + } + + if (typeof candidate.contentSha256 !== 'string') { + warnings.push( + `Preserved previously managed file ${safePath}: the recorded operation has no content digest, so the file cannot be verified unchanged; remove it manually if unwanted.` + ); + continue; + } + + let currentDigest; + try { + currentDigest = hashFileNoFollow(safePath); + } catch (error) { + warnings.push(`Preserved previously managed file ${safePath}: ${error.message}`); + continue; + } + + if (currentDigest !== candidate.contentSha256.toLowerCase()) { + warnings.push( + `Preserved previously managed file ${safePath}: content changed after install; remove it manually if unwanted.` + ); + continue; + } + + fs.unlinkSync(safePath); + removedPaths.push(safePath); + removeEmptyParents(safePath, plan.targetRoot); + } + + return { removedPaths, warnings }; +} + +module.exports = { + completeExcludedPathsReconciliation, + prepareExcludedPathsReconciliation, +}; diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index c1e935dcc..576ba4389 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -7,6 +7,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync, spawnSync } = require('child_process'); +const crypto = require('crypto'); const yaml = require('js-yaml'); const { applyInstallPlan } = require('../../scripts/lib/install/apply'); @@ -641,6 +642,134 @@ function runTests() { } })) passed++; else failed++; + if (test('reconciles legacy .agents files and state operations on Claude and Codex home upgrades', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + const digest = content => crypto.createHash('sha256').update(content).digest('hex'); + const legacyOperation = (destinationPath, sourceRelativePath, installedContent) => ({ + kind: 'copy-file', + moduleId: 'agents-core', + sourceRelativePath, + destinationPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + contentSha256: digest(installedContent), + }); + const writeFile = (filePath, content) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + }; + const writeLegacyState = (statePath, target, operations) => { + writeFile(statePath, `${JSON.stringify({ + schemaVersion: 'ecc.install.v1', + installedAt: '2026-09-01T00:00:00.000Z', + target, + request: { + profile: 'core', + modules: [], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + hookConsent: target.target === 'claude' ? 'enabled' : null, + }, + resolution: { selectedModules: ['agents-core'], skippedModules: [] }, + source: { repoVersion: '2.2.1', repoCommit: null, manifestVersion: 1 }, + operations, + }, null, 2)}\n`); + }; + + try { + // Claude home seeded as installed before the .agents exclusion. + const claudeRoot = path.join(homeDir, '.claude'); + const claudeStatePath = path.join(claudeRoot, 'ecc', 'install-state.json'); + const claudeSkillCopy = path.join(claudeRoot, '.agents', 'skills', 'legacy-skill', 'SKILL.md'); + const claudeModifiedCopy = path.join(claudeRoot, '.agents', 'plugins', 'marketplace.json'); + const claudeUserFile = path.join(claudeRoot, '.agents', 'user-note.txt'); + writeFile(claudeSkillCopy, '# legacy skill\n'); + writeFile(claudeModifiedCopy, '{"edited": true}\n'); + writeFile(claudeUserFile, 'user notes\n'); + writeLegacyState(claudeStatePath, { + id: 'claude-home', target: 'claude', kind: 'home', + root: claudeRoot, installStatePath: claudeStatePath, + }, [ + legacyOperation(claudeSkillCopy, '.agents/skills/legacy-skill/SKILL.md', '# legacy skill\n'), + legacyOperation(claudeModifiedCopy, '.agents/plugins/marketplace.json', '{"original": true}\n'), + ]); + + const claudeResult = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); + assert.strictEqual(claudeResult.code, 0, claudeResult.stderr); + + assert.ok(!fs.existsSync(claudeSkillCopy), 'Unchanged managed .agents file should be removed'); + assert.ok( + claudeResult.stdout.includes( + `- removed ${path.join(fs.realpathSync(claudeRoot), '.agents', 'skills', 'legacy-skill', 'SKILL.md')}` + ), + 'Install output should log one line per removed path' + ); + assert.strictEqual( + fs.readFileSync(claudeModifiedCopy, 'utf8'), + '{"edited": true}\n', + 'Modified managed file must be preserved' + ); + assert.strictEqual( + fs.readFileSync(claudeUserFile, 'utf8'), + 'user notes\n', + 'Files the state does not own must not be touched' + ); + assert.ok( + !fs.existsSync(path.join(claudeRoot, '.agents', 'skills')), + 'Emptied .agents subdirectories should be pruned' + ); + + const claudeState = readJson(claudeStatePath); + assert.ok( + !claudeState.operations.some(operation => ( + String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents' + )), + 'Claude install-state must drop the excluded .agents operations' + ); + assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); + + // Codex home seeded the same way; both recorded files are unchanged. + const codexRoot = path.join(homeDir, '.codex'); + const codexStatePath = path.join(codexRoot, 'ecc-install-state.json'); + const codexSkillCopy = path.join(codexRoot, '.agents', 'skills', 'legacy-skill', 'SKILL.md'); + const codexMarketplaceCopy = path.join(codexRoot, '.agents', 'plugins', 'marketplace.json'); + writeFile(codexSkillCopy, '# legacy skill\n'); + writeFile(codexMarketplaceCopy, '{"original": true}\n'); + writeLegacyState(codexStatePath, { + id: 'codex-home', target: 'codex', kind: 'home', + root: codexRoot, installStatePath: codexStatePath, + }, [ + legacyOperation(codexSkillCopy, '.agents/skills/legacy-skill/SKILL.md', '# legacy skill\n'), + legacyOperation(codexMarketplaceCopy, '.agents/plugins/marketplace.json', '{"original": true}\n'), + ]); + + const codexResult = run(['--target', 'codex', '--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(codexResult.code, 0, codexResult.stderr); + + assert.ok( + !fs.existsSync(path.join(codexRoot, '.agents')), + 'Fully reconciled .agents directory should be pruned from the Codex home' + ); + const codexState = readJson(codexStatePath); + assert.ok( + !codexState.operations.some(operation => ( + String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents' + )), + 'Codex install-state must drop the excluded .agents operations' + ); + assert.ok(fs.existsSync(path.join(codexRoot, 'agents', 'architect.md'))); + assert.ok(fs.existsSync(path.join(codexRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('preserves existing top-level Claude rules and skills during managed install', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-');