fix(cli): make skills update converge on a skill retired upstream (#2176)

`hyperframes skills update` failed hard or looped forever once a skill was
retired/renamed upstream while still installed locally (hyperframes-media folded
into media-use; hyperframes-captions/compose/tts consolidated earlier). Two
paths dead-ended:

- Install: target selection could trust a stale local skills-manifest.json
  (findRepoManifest) while `skills add` always installs from the canonical repo.
  isCoreSkill matches the `hyperframes-` prefix, so a retired skill was forced
  into the target set, `skills add` silently declined it (exit 0), and strict
  verifyInstalled threw "Skill(s) still missing after install".
- Prune: upstream `skills remove` scans on-disk directories, so a lock entry
  retired before it ever shipped a bundle has nothing to match — a silent
  exit-0 no-op that never clears the lock, so detectRemoved re-flags it on
  every run.

The stale-skills nudge compounded it: it fired even from `skills update` itself
(pointing users back at the failing command) and its count ignored the removed
bucket.

Resolve update targets against the canonical manifest (checkSkills({ canonical:
true })) so a retired skill is never targeted. Add pruneOrphanedLockEntries to
clear the orphaned lock entries the upstream remover can't (idempotent, so a
second run is a clean no-op). Exclude `skills` from the update-nudge gate and
thread the removed count through the nudge total.
This commit is contained in:
Miguel Ángel
2026-07-10 19:57:55 -04:00
committed by GitHub
parent 1d97ddaf8c
commit b1f1c0571e
9 changed files with 587 additions and 12 deletions
+15 -3
View File
@@ -16,6 +16,8 @@ export interface SkillsUpdateMeta {
updateAvailable: boolean;
outdated: number;
missing: number;
/** Installed skills flagged removed-upstream (renamed/dropped) at the last check. */
removed: number;
}
/** Synchronous read from cache — never fetches. */
@@ -25,6 +27,7 @@ function getSkillsUpdateMeta(): SkillsUpdateMeta {
updateAvailable: config.skillsUpdateAvailable ?? false,
outdated: config.skillsOutdatedCount ?? 0,
missing: config.skillsMissingCount ?? 0,
removed: config.skillsRemovedCount ?? 0,
};
}
@@ -35,7 +38,10 @@ function cacheFresh(lastSkillsCheck: string | undefined, now: number): boolean {
/** Run the real check and persist the result to the cache. */
async function refreshSkillsCache(): Promise<SkillsUpdateMeta> {
const result = await checkSkills();
// `canonical: true` so this nudge's counts agree with `updateSkills`'s
// source of truth — otherwise a stale in-repo skills-manifest.json (e.g.
// inside a hyperframes checkout) can produce a false-positive count here.
const result = await checkSkills({ canonical: true });
// Only record a meaningful check when skills were actually found.
if (result.location) {
const config = readConfig();
@@ -45,12 +51,18 @@ async function refreshSkillsCache(): Promise<SkillsUpdateMeta> {
// Core-missing only: skills that install on demand (workflows not yet
// triggered on this machine) are not "missing" worth nagging about.
config.skillsMissingCount = result.summary.coreMissing;
// Removed-upstream skills are just as reconcilable as outdated/missing
// ones (a plain `skills update` prunes them) — omitting them here is what
// made the nudge undercount (e.g. reporting "2 skills out of date or
// missing" while a 3rd, renamed/dropped skill sat unmentioned).
config.skillsRemovedCount = result.summary.removed;
writeConfig(config);
}
return {
updateAvailable: result.updateAvailable,
outdated: result.summary.outdated,
missing: result.summary.coreMissing,
removed: result.summary.removed,
};
}
@@ -70,9 +82,9 @@ export async function checkSkillsForUpdate(force?: boolean): Promise<SkillsUpdat
}
}
/** The stale-skills nudge text, or null when nothing is outdated or missing. */
/** The stale-skills nudge text, or null when nothing is outdated, missing, or removed. */
function skillsNoticeText(meta: SkillsUpdateMeta): string | null {
const total = meta.outdated + meta.missing;
const total = meta.outdated + meta.missing + meta.removed;
if (total < 1) return null;
const noun = total === 1 ? "skill" : "skills";
return `\n ${total} HyperFrames ${noun} out of date or missing.\n Run: npx hyperframes skills update\n\n`;