fix(skills_sync): don't poison manifest on new-skill collision

When a new bundled skill's name collided with a pre-existing user skill
(from hub, custom, or leftover), sync_skills() recorded the bundled hash
in the manifest even though the on-disk copy was unrelated to bundled.
On the next sync, user_hash != origin_hash (bundled_hash) marked the
skill as "user-modified" permanently, blocking all bundled updates for
that skill until the user ran `hermes skills reset`.

Fix: only baseline the manifest entry when the user's on-disk copy is
byte-identical to bundled (safe to track — this is the reset re-sync or
coincidentally-identical install case). Otherwise skip the manifest
write entirely: the on-disk skill is unrelated to bundled and shouldn't
be tracked as if it were.

This preserves reset_bundled_skill()'s re-baseline flow (its post-delete
sync still writes to the manifest when user copy matches bundled) while
fixing the poisoning scenario for genuinely unrelated collisions.

Adds two tests following the existing test_failed_copy_does_not_poison_manifest
pattern: one verifying the manifest stays clean after a collision with
differing content, one verifying no false user_modified flag on resync.
This commit is contained in:
j0sephz 2026-04-23 07:48:10 +03:00 committed by Teknium
parent 91d6ea07c8
commit 3a97fb3d47
2 changed files with 69 additions and 2 deletions

View file

@ -206,9 +206,18 @@ def sync_skills(quiet: bool = False) -> dict:
# ── New skill — never offered before ──
try:
if dest.exists():
# User already has a skill with the same name — don't overwrite
# User already has a skill with the same name — don't overwrite.
# Only baseline in the manifest when the on-disk copy is
# byte-identical to bundled (e.g. a reset that re-syncs, or
# a coincidentally identical install); that case is harmless
# to track. If the copy differs (custom skill, hub-installed,
# or user-edited) skip the manifest write: recording
# bundled_hash there would poison update detection by making
# user_hash != origin_hash read as "user-modified" on every
# subsequent sync, permanently blocking bundled updates.
skipped += 1
manifest[skill_name] = bundled_hash
if _dir_hash(dest) == bundled_hash:
manifest[skill_name] = bundled_hash
else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(skill_src, dest)