Summary
mcs sync removes file artifacts by calling FileManager.removeItem on the recorded path without checking whether the file still matches what mcs installed. If a user edits an mcs-installed skill, agent, hook script, or command and later deselects the pack, their edits are deleted with no warning.
This is silent data loss, and it affects exactly the artifacts users are most likely to hand-edit after installation.
Reproduction
mcs sync with a pack that ships a skill.
- Edit the installed skill's markdown to taste.
- Re-run
mcs sync and deselect that pack.
- The edited file is gone. Output shows only
Removed: <path>.
Root cause
Both SyncStrategy implementations delete unconditionally:
Sources/mcs/Sync/ProjectSyncStrategy.swift — removeFileArtifact(relativePath:output:)
Sources/mcs/Sync/GlobalSyncStrategy.swift — same method
Each resolves the path via PathContainment.safePath, checks fileExists, then calls try fm.removeItem(at: fullPath). There is no content comparison.
The information needed is already recorded and already used elsewhere. PackArtifactRecord.fileHashes stores a SHA-256 per installed file, and FileContentCheck uses it to report drift in mcs doctor. Removal simply never consults it — Configurator.unconfigurePack iterates artifacts.files, deletes each one, and only afterwards purges fileHashes for the deleted entries.
The structural blocker is the protocol signature: removeFileArtifact(relativePath:output:) receives only a relative path, so a strategy has no access to the recorded hash even though the caller holds it.
Proposed fix
- Pass the expected hash to the removal call — widen the
SyncStrategy method so the strategy can compare before deleting.
- Compare before deleting. Hash matches the record → remove. Hash differs → preserve the file and warn, naming the path and the reason it was kept.
- Track files individually, never a directory as one artifact. A skill is a directory; if the user added a file inside it, directory-level removal takes that too. Remove tracked files individually and remove the directory only once it is empty.
lstat before mutation and refuse to follow a symlink substituted for a tracked path.
- Consider recording an
origin (created vs adopted) on the artifact record so a future adopt-an-existing-file path cannot delete something mcs never installed.
Note on state schema
⚠️ If this adds a field to the state file, be careful which type it goes on. PackArtifactRecord has a hand-written init(from:) using decodeIfPresent, so adding a field there is non-breaking. StateStorage does not — it relies on synthesized Codable, whose decoder does not consult property defaults for missing keys, so its non-optional defaulted properties are effectively required. Adding a non-optional field to StateStorage breaks every existing state file. Make it Optional or give StateStorage the same hand-written decoder, and cover it with a decode test against a pre-field state file.
Acceptance criteria
Related question to answer while in this code
ExternalPackLoader throws on an incompatible minMCSVersion, but loadAll — the sync path — catches and downgrades to warn-and-skip. A version-skipped pack is then absent from the list handed to Configurator while ProjectState still lists it in configuredPacks with a populated PackArtifactRecord — the same shape as a user deselection.
Verify whether the removal path fires in that case. If it does, an older mcs meeting a newer pack silently tears down that pack's artifacts, which is a second data-loss path through the same code and should be fixed here.
Summary
mcs syncremoves file artifacts by callingFileManager.removeItemon the recorded path without checking whether the file still matches what mcs installed. If a user edits an mcs-installed skill, agent, hook script, or command and later deselects the pack, their edits are deleted with no warning.This is silent data loss, and it affects exactly the artifacts users are most likely to hand-edit after installation.
Reproduction
mcs syncwith a pack that ships a skill.mcs syncand deselect that pack.Removed: <path>.Root cause
Both
SyncStrategyimplementations delete unconditionally:Sources/mcs/Sync/ProjectSyncStrategy.swift—removeFileArtifact(relativePath:output:)Sources/mcs/Sync/GlobalSyncStrategy.swift— same methodEach resolves the path via
PathContainment.safePath, checksfileExists, then callstry fm.removeItem(at: fullPath). There is no content comparison.The information needed is already recorded and already used elsewhere.
PackArtifactRecord.fileHashesstores a SHA-256 per installed file, andFileContentCheckuses it to report drift inmcs doctor. Removal simply never consults it —Configurator.unconfigurePackiteratesartifacts.files, deletes each one, and only afterwards purgesfileHashesfor the deleted entries.The structural blocker is the protocol signature:
removeFileArtifact(relativePath:output:)receives only a relative path, so a strategy has no access to the recorded hash even though the caller holds it.Proposed fix
SyncStrategymethod so the strategy can compare before deleting.lstatbefore mutation and refuse to follow a symlink substituted for a tracked path.origin(created vs adopted) on the artifact record so a future adopt-an-existing-file path cannot delete something mcs never installed.Note on state schema
PackArtifactRecordhas a hand-writteninit(from:)usingdecodeIfPresent, so adding a field there is non-breaking.StateStoragedoes not — it relies on synthesizedCodable, whose decoder does not consult property defaults for missing keys, so its non-optional defaulted properties are effectively required. Adding a non-optional field toStateStoragebreaks every existing state file. Make itOptionalor giveStateStoragethe same hand-written decoder, and cover it with a decode test against a pre-field state file.Acceptance criteria
LifecycleIntegrationTests: install a skill, modify it on disk, deselect the pack, assert the file still exists with the user's content and that drift is reported. This test must fail onmaintoday.Related question to answer while in this code
ExternalPackLoaderthrows on an incompatibleminMCSVersion, butloadAll— the sync path — catches and downgrades to warn-and-skip. A version-skipped pack is then absent from the list handed toConfiguratorwhileProjectStatestill lists it inconfiguredPackswith a populatedPackArtifactRecord— the same shape as a user deselection.Verify whether the removal path fires in that case. If it does, an older mcs meeting a newer pack silently tears down that pack's artifacts, which is a second data-loss path through the same code and should be fixed here.