happyskills 1.15.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.16.1] - 2026-07-02
11
+
12
+ ### Fixed
13
+
14
+ - Make the passive daily skill-update check watch **every** installed skill, not just top-level (`__root__`) ones. Bundled family satellites installed as transitive dependencies (e.g. `happyskills-design`, `happyskills-help`) are recorded in the lock as `requested_by: [<parent>]`, so the previous `__root__`-only filter silently never nudged when they fell behind the registry — even though the check already spans both the project-local and global locks. Candidate selection now mirrors `happyskills check` (every installed skill), so an outdated bundled satellite is surfaced both in the stderr nudge and in the `SKILLS_UPDATE_AVAILABLE` envelope warning.
15
+
16
+ ### Changed
17
+
18
+ - The "installed skills behind the registry" nudge now names the outdated skills in its suggested command (`npx happyskills update <owner/name> …`, grouped by scope so global skills get `-g`) instead of suggesting `update --all` — which only re-checks top-level skills and therefore could never refresh an outdated bundled satellite.
19
+
20
+ ## [1.16.0] - 2026-07-01
21
+
22
+ ### Changed
23
+
24
+ - Make `happyskills match` recognise a local skill as `certified` when its **functional content matches any published version** — not just a byte-identical head. For every hash/name candidate, `match` now enumerates the repo's whole version history (`GET /refs`) and fetches each version's per-file manifest (path + blob SHA only, never content), then runs a **cross-version containment check**: a version is a match when all of its functional files (everything except `skill.json` and `CHANGELOG.md`) are present locally with the same SHA. `SKILL.md` is the identity anchor — containment can't hold unless the local `SKILL.md` equals that version's. The **earliest** contained version is certified (`match.version`) — where the content originated, so a metadata-only re-release doesn't shift the reported version — carrying `match.latest_version` + `match.is_latest` so a behind-by copy is clear. This is what lets a raw local folder with **no `skill.json` at all** — whose content reproduces v0.3.1 — report `certified @ 0.3.1` even though head is v0.3.2, instead of misreporting as `near`/`likely` against head. **Extra local files are tolerated** (a stray `__pycache__/*.pyc`, an editor swap) — they can't block identity — but are surfaced in `local_only_files` so a genuine fork isn't hidden. `skill.json`/`CHANGELOG.md` are excluded from the containment check, `differing_files`, and the similarity denominator (root-level, case-insensitive; `LICENSE` still counts). Purely client-side — no API or registry change. The text output gains "Certified to an older published version" and "Certified, with extra local-only files" lines. (Spec 260629-03 §4.9/§4.10.)
25
+
10
26
  ## [1.15.0] - 2026-06-30
11
27
 
12
28
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.15.0",
3
+ "version": "1.16.1",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
package/src/api/repos.js CHANGED
@@ -84,6 +84,18 @@ const get_refs = (owner, repo) => catch_errors(`Get refs for ${owner}/${repo} fa
84
84
  return data
85
85
  })
86
86
 
87
+ // Published version strings for a repo, newest-agnostic (the funnel sorts).
88
+ // Parses `refs/tags/v<semver>` tags from the refs list — used by `happyskills
89
+ // match` to scan every version for a cross-version containment match.
90
+ const list_versions = (owner, repo) => catch_errors(`List versions for ${owner}/${repo} failed`, async () => {
91
+ const [errors, data] = await client.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/refs`)
92
+ if (errors) throw errors[errors.length - 1]
93
+ const refs = Array.isArray(data) ? data : (data && (data.results || data.refs)) || []
94
+ return refs
95
+ .map(r => { const m = String((r && r.name) || r).match(/^refs\/tags\/v?(.+)$/); return m ? m[1] : null })
96
+ .filter(Boolean)
97
+ })
98
+
87
99
  const get_repo = (owner, repo, options = {}) => catch_errors(`Get repo ${owner}/${repo} failed`, async () => {
88
100
  const [errors, data] = await client.get(`/repos/${owner}/${repo}`, { auth: options.auth || false })
89
101
  if (errors) throw errors[errors.length - 1]
@@ -238,4 +250,4 @@ const manifests = (repo_versions) => catch_errors('Manifests lookup failed', asy
238
250
  }
239
251
  })
240
252
 
241
- module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, star, unstar, compare, get_blob, get_tree, match, manifests }
253
+ module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, list_versions, get_repo, check_updates, del_repo, patch_repo, star, unstar, compare, get_blob, get_tree, match, manifests }
@@ -67,6 +67,7 @@ const run = (args) => catch_errors('Match failed', async () => {
67
67
  const deps = {
68
68
  match: repos_api.match,
69
69
  manifests: repos_api.manifests,
70
+ versions: (workspace, name) => repos_api.list_versions(workspace, name),
70
71
  semantic_search: (q) => repos_api.semantic_search(q, { limit: 3 }),
71
72
  }
72
73
  const [funnel_err, funnel] = await run_funnel(scanned.skills, deps, { threshold, concurrency })
@@ -129,19 +130,32 @@ const run = (args) => catch_errors('Match failed', async () => {
129
130
  const render_text = (data, warnings) => {
130
131
  const s = data.summary
131
132
  print_info(`Scanned ${s.total} skill${s.total === 1 ? '' : 's'}.`)
132
- print_success(`✓ ${s.certified} already in HappySkills, unchanged · ≈ ${s.near} with small differences · ~ ${s.likely + s.possible} loosely matched · ✗ ${s.unmatched} not found`)
133
+ print_success(`✓ ${s.certified} certified (content matches a published version) · ≈ ${s.near} with small differences · ~ ${s.likely + s.possible} loosely matched · ✗ ${s.unmatched} not found`)
133
134
 
134
135
  // A certified match to an OLDER version is still an exact match — surface the
135
136
  // version gap so the principal knows it is the real skill, just behind.
136
137
  const behind = data.certified.filter(en => en.match && en.match.latest_version && en.match.is_latest === false)
137
138
  if (behind.length) {
138
- print_info('\nIdentical to an older published version (you are behind):')
139
+ print_info('\nCertified to an older published version (you are behind):')
139
140
  for (const en of behind) {
140
141
  const where = en.match ? `${en.match.workspace}/${en.match.name}` : '(catalog)'
141
142
  print_info(` ${en.local.name} → ${where} @ ${en.match.version} (latest ${en.match.latest_version})`)
142
143
  }
143
144
  }
144
145
 
146
+ // Certified, but the local copy carries extra files the published version
147
+ // does not (build cruft like __pycache__/*.pyc, or genuine local additions).
148
+ // Certification stands — the skill's content is fully present — but show the
149
+ // extras so a real fork isn't hidden as "identical".
150
+ const with_extras = data.certified.filter(en => en.local_only_files && en.local_only_files.length)
151
+ if (with_extras.length) {
152
+ print_info('\nCertified, with extra local-only files (not part of the published skill):')
153
+ for (const en of with_extras) {
154
+ const where = en.match ? `${en.match.workspace}/${en.match.name}@${en.match.version}` : '(catalog)'
155
+ print_info(` ${en.local.name} → ${where} — local extras: ${en.local_only_files.join(', ')}`)
156
+ }
157
+ }
158
+
145
159
  if (data.near.length) {
146
160
  print_info('\nSkills with small differences:')
147
161
  for (const en of data.near) {
@@ -27,6 +27,7 @@ require.cache[repos_path] = {
27
27
  exports: {
28
28
  match: async () => [null, { results: api_state.results, name_results: api_state.name_results }],
29
29
  manifests: async () => [null, { manifests: api_state.manifests, warnings: [] }],
30
+ list_versions: async (ws, name) => [null, [...new Set(api_state.manifests.filter(m => m.workspace === ws && m.name === name).map(m => m.version))]],
30
31
  semantic_search: async () => [null, api_state.semantic],
31
32
  },
32
33
  }
package/src/match/diff.js CHANGED
@@ -4,9 +4,17 @@
4
4
  // [{ path, blob_sha }] lists. The differing-file list is the load-bearing
5
5
  // artifact (not the single similarity %): it's what tells the principal exactly
6
6
  // what changed (e.g. "only LICENSE differs").
7
+ //
8
+ // opts.ignore(path) → true marks a file as non-functional / platform-specific
9
+ // (spec §4.9): it is EXCLUDED from the functional diff and the similarity
10
+ // denominator, so it can never count toward "is this the same skill". Ignored
11
+ // files that differ are reported separately as `ignored_differing`, so the
12
+ // caller can flag a match as "metadata-only differs" rather than silently
13
+ // dropping the fact. Omit opts.ignore for the original whole-tree behavior.
7
14
 
8
15
  // local_files / catalog_files: [{ path, blob_sha }]
9
- const diff_manifests = (local_files, catalog_files) => {
16
+ const diff_manifests = (local_files, catalog_files, opts = {}) => {
17
+ const ignore = typeof opts.ignore === 'function' ? opts.ignore : () => false
10
18
  const local = new Map((local_files || []).map(f => [f.path, f.blob_sha]))
11
19
  const catalog = new Map((catalog_files || []).map(f => [f.path, f.blob_sha]))
12
20
  const all = new Set([...local.keys(), ...catalog.keys()])
@@ -15,16 +23,22 @@ const diff_manifests = (local_files, catalog_files) => {
15
23
  const modified = []
16
24
  const added = [] // present locally, absent in the catalog skill
17
25
  const removed = [] // present in the catalog skill, absent locally
26
+ const ignored_differing = [] // ignored files that differ (metadata-only deltas)
18
27
 
19
28
  for (const p of all) {
20
29
  const l = local.get(p)
21
30
  const c = catalog.get(p)
31
+ if (ignore(p)) {
32
+ if (l !== c) ignored_differing.push(p) // present-both-modified, or added/removed
33
+ continue
34
+ }
22
35
  if (l !== undefined && c !== undefined) (l === c ? identical : modified).push(p)
23
36
  else if (l !== undefined) added.push(p)
24
37
  else removed.push(p)
25
38
  }
26
39
 
27
- const total = all.size
40
+ // Similarity is over FUNCTIONAL files only (ignored files are out of the set).
41
+ const total = identical.length + modified.length + added.length + removed.length
28
42
  const similarity = total === 0 ? 1 : identical.length / total
29
43
  const differing = [...modified, ...added, ...removed].sort()
30
44
 
@@ -35,6 +49,7 @@ const diff_manifests = (local_files, catalog_files) => {
35
49
  added: added.sort(),
36
50
  removed: removed.sort(),
37
51
  differing,
52
+ ignored_differing: ignored_differing.sort(),
38
53
  }
39
54
  }
40
55
 
@@ -1,19 +1,26 @@
1
1
  'use strict'
2
- // The match funnel + tier classification (spec 260629-03 §4.7).
2
+ // The match funnel + tier classification (spec 260629-03 §4.7 / §4.10).
3
3
  //
4
4
  // Stages, cheapest signal first, NO blob downloads to classify:
5
- // 1. hash + name — ONE batched POST /repos:match for ALL local skills.
6
- // 2. tree SHA — local.tree_sha === a candidate's tree_sha ⇒ certified
7
- // (byte-identical, zero further work).
8
- // 3. manifest — for non-certified hash/name candidates, fetch the narrowed
9
- // source-of-truth manifest (bounded fan-out, no blob content)
10
- // and diff per-file. A hash match means SKILL.md is identical
11
- // by construction favor `near` and just list the differing
12
- // files. A name-only match is `near` above the similarity
13
- // threshold, else `likely`.
14
- // 4. semantic — skills with no hash/name match fall back to the deployed
15
- // semantic search (bounded), labeled `possible` (clearly low
16
- // confidence), else `unmatched`.
5
+ // 1. batch — ONE POST /repos:match for the whole folder (hashes + names +
6
+ // tree shas).
7
+ // 2. tree SHA — local.tree_sha === a candidate's tree_sha ⇒ certified,
8
+ // byte-identical, at that EXACT version (even an older one),
9
+ // zero further work.
10
+ // 3. versions — for every remaining hash/name candidate, enumerate ALL the
11
+ // repo's versions and fetch each version's per-file manifest
12
+ // (path + blob SHA only never blob content; bounded + chunked).
13
+ // 3.5 contain — a version is a match when all its FUNCTIONAL files (everything
14
+ // except skill.json / CHANGELOG.md) are present locally with the
15
+ // same blob SHA (catalog ⊆ local). SKILL.md is the identity
16
+ // anchor — it is functional, so containment can't hold unless the
17
+ // local SKILL.md equals that version's. Extra local files (dirt
18
+ // like __pycache__/*.pyc) are tolerated and surfaced as
19
+ // `local_only_files`. Certify the EARLIEST contained version (where
20
+ // the content originated); else near/likely against the closest
21
+ // version by functional similarity.
22
+ // 4. semantic — skills with no hash/name match fall back to the deployed
23
+ // semantic search (bounded), labeled `possible`, else `unmatched`.
17
24
  //
18
25
  // Closed tier set: certified · near · likely · possible · unmatched.
19
26
 
@@ -21,12 +28,34 @@ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
21
28
  const { bounded, DEFAULT_CONCURRENCY } = require('../utils/concurrency')
22
29
  const { diff_manifests } = require('./diff')
23
30
 
24
- // Identical-file fraction above which a NAME-only match is promoted to `near`.
31
+ // Identical-file fraction above which a non-contained match counts as `near`.
25
32
  const DEFAULT_THRESHOLD = 0.5
26
33
  // Mirror of the server cap (MATCH_MAX_MANIFESTS) so we chunk manifest requests.
27
34
  const MANIFEST_CHUNK = 50
35
+ // Safety bound on the cross-version scan — a repo with more tags than this has
36
+ // its newest N scanned (surfaced as a warning; never a silent cap).
37
+ const MAX_VERSIONS_PER_REPO = 50
28
38
 
29
- const key_of = (c) => `${String(c.workspace).toLowerCase()}/${c.name}@${c.version}`
39
+ // Non-functional / platform-specific files that do NOT count toward skill
40
+ // identity (spec 260629-03 §4.9). `skill.json` is HappySkills metadata — always
41
+ // present, and its `version` bumps on every publish; `CHANGELOG.md` is
42
+ // non-functional release history. Both are excluded from the containment check
43
+ // and the similarity denominator. Matched on the lowercased path, so only the
44
+ // ROOT files count (a `references/CHANGELOG.md` still counts as real content).
45
+ const IGNORE_FILES = new Set(['skill.json', 'changelog.md'])
46
+ const is_ignored_file = (p) => IGNORE_FILES.has(String(p).toLowerCase())
47
+
48
+ const repo_key = (c) => `${String(c.workspace).toLowerCase()}/${c.name}`
49
+ const key_of = (c) => `${repo_key(c)}@${c.version}`
50
+
51
+ // Descending semver-ish compare (numeric, dot-separated) — surfaces the NEWEST
52
+ // matching version first.
53
+ const cmp_version_desc = (a, b) => {
54
+ const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0)
55
+ const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0)
56
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pb[i] || 0) - (pa[i] || 0); if (d) return d }
57
+ return 0
58
+ }
30
59
 
31
60
  const local_view = (skill) => ({
32
61
  name: skill.name,
@@ -40,16 +69,18 @@ const match_view = (c) => ({
40
69
  workspace: c.workspace,
41
70
  name: c.name,
42
71
  version: c.version,
43
- tree_sha: c.tree_sha,
72
+ tree_sha: c.tree_sha || null,
44
73
  is_source_of_truth: !!c.is_source_of_truth,
45
- // Cross-version probe carries the repo head so a certified-but-older match
46
- // can be surfaced as "certified @ 0.3.0 (latest 0.3.2)". Null when the
47
- // candidate came from the legacy head-only path (older server).
74
+ // The repo head, so a certified-but-older match can be surfaced as
75
+ // "certified @ 0.3.1 (latest 0.3.2)". `is_latest` compares the matched
76
+ // version to head. Null when the candidate came from a head-only path.
48
77
  latest_version: c.latest_version || null,
49
78
  is_latest: c.latest_version ? c.version === c.latest_version : null,
50
79
  })
51
80
 
52
- // deps: { match(hashes,names), manifests(repo_versions), semantic_search?(query) }
81
+ // deps: { match(hashes,names,tree_shas), manifests(repo_versions),
82
+ // versions?(workspace,name), semantic_search?(query) } — each returns a
83
+ // puffy [err, data] tuple.
53
84
  // opts: { threshold, concurrency }
54
85
  const run_funnel = (skills, deps, opts = {}) =>
55
86
  catch_errors('Match funnel failed', async () => {
@@ -61,9 +92,6 @@ const run_funnel = (skills, deps, opts = {}) =>
61
92
  if (!skills.length) return { buckets, warnings }
62
93
 
63
94
  // ── Stage 1: one batched match for the whole folder ──────────────────
64
- // Hashes, names AND whole-skill tree shas ride the same call. The tree
65
- // probe (spec §4.8) is what lets an OLDER local copy match its exact
66
- // published version instead of drifting against head.
67
95
  const hashes = [...new Set(skills.map(s => s.skill_md_sha).filter(Boolean))]
68
96
  const names = [...new Set(skills.map(s => s.name).filter(Boolean))]
69
97
  const tree_shas = [...new Set(skills.map(s => s.tree_sha).filter(Boolean))]
@@ -74,42 +102,76 @@ const run_funnel = (skills, deps, opts = {}) =>
74
102
  const by_name = new Map((match_data.name_results || []).map(r => [String(r.name).toLowerCase(), r.candidates || []]))
75
103
  const by_tree = new Map((match_data.tree_results || []).map(r => [r.tree_sha, r.candidates || []]))
76
104
 
77
- // ── Stage 2: classify; collect the narrowed manifest set ─────────────
78
- const pending = [] // needs a manifest diff
79
- const none = [] // no hash/name match → semantic stage
80
- const manifest_needs = new Map()
105
+ // ── Stage 2: fast-path certify (byte-exact) + collect candidate repos ─
106
+ const pending = [] // { skill, cand } — needs a cross-version scan
107
+ const none = [] // no hash/name match → semantic stage
108
+ const repo_needs = new Map() // "ws/name" -> { workspace, name, head_version, is_source_of_truth }
81
109
 
82
110
  for (const skill of skills) {
83
111
  const tree_cands = (skill.tree_sha && by_tree.get(skill.tree_sha)) || []
84
112
  const hash_cands = (skill.skill_md_sha && by_hash.get(skill.skill_md_sha)) || []
85
113
  const name_cands = by_name.get(String(skill.name || '').toLowerCase()) || []
86
114
 
87
- // Certified = byte-identical to SOME published version. The cross-version
88
- // tree probe is authoritative and pins the EXACT version (even an older
89
- // one candidates are source-of-truth + newest-matching first). Fall
90
- // back to head tree-SHA equality from a hash/name candidate so an older
91
- // server with no tree_results still certifies a head-identical skill.
92
- const certified = tree_cands[0] || [...hash_cands, ...name_cands].find(c => c.tree_sha === skill.tree_sha)
93
- if (certified) {
94
- buckets.certified.push({ local: local_view(skill), match: match_view(certified) })
115
+ // Byte-identical to a published version (tree probe) → certified at that
116
+ // exact version, zero manifest fetches. Falls back to head tree-SHA
117
+ // equality for an older server that returns no tree_results.
118
+ const exact = tree_cands[0] || [...hash_cands, ...name_cands].find(c => c.tree_sha === skill.tree_sha)
119
+ if (exact) {
120
+ buckets.certified.push({ local: local_view(skill), match: match_view(exact) })
95
121
  continue
96
122
  }
97
- if (hash_cands.length) {
98
- const sot = hash_cands[0] // API returns source-of-truth first
99
- manifest_needs.set(key_of(sot), { workspace: sot.workspace, name: sot.name, version: sot.version })
100
- pending.push({ skill, cand: sot, via: 'hash' })
101
- continue
102
- }
103
- if (name_cands.length) {
104
- const sot = name_cands[0]
105
- manifest_needs.set(key_of(sot), { workspace: sot.workspace, name: sot.name, version: sot.version })
106
- pending.push({ skill, cand: sot, via: 'name' })
123
+
124
+ // A hash/name candidate points at a repo — scan ALL its versions for a
125
+ // functional-containment match. Prefer the source-of-truth repo (the
126
+ // API lists candidates SoT-first), which collapses cross-repo forks.
127
+ const cand = hash_cands[0] || name_cands[0]
128
+ if (cand) {
129
+ // `via: hash` means the local SKILL.md equals head's — a strong
130
+ // same-skill signal that keeps a non-contained match at `near` (not
131
+ // `likely`) even if a reference differs or the manifest can't be read.
132
+ const via = hash_cands[0] ? 'hash' : 'name'
133
+ repo_needs.set(repo_key(cand), { workspace: cand.workspace, name: cand.name, head_version: cand.version, is_source_of_truth: cand.is_source_of_truth })
134
+ pending.push({ skill, cand, via })
107
135
  continue
108
136
  }
109
137
  none.push(skill)
110
138
  }
111
139
 
112
- // ── Stage 3: bounded manifest fetch (chunked to the server cap) ──────
140
+ // ── Stage 2.5: enumerate each candidate repo's versions ──────────────
141
+ const versions_by_repo = new Map()
142
+ if (repo_needs.size && typeof deps.versions === 'function') {
143
+ const reqs = [...repo_needs.values()]
144
+ // Promise.resolve wrapper so a synchronously-throwing versions dep is
145
+ // caught here (→ head-only fallback) rather than escaping the thunk.
146
+ const results = await bounded(reqs.map(r => () =>
147
+ Promise.resolve().then(() => deps.versions(r.workspace, r.name)).then(res => ({ r, res })).catch(error => ({ r, error }))), concurrency)
148
+ for (const { r, res, error } of results) {
149
+ const key = repo_key(r)
150
+ const [verr, vs] = Array.isArray(res) ? res : [error || null, null]
151
+ if (verr || error || !Array.isArray(vs) || !vs.length) {
152
+ if (verr || error) warnings.push({ code: 'VERSIONS_FETCH_FAILED', message: `Could not list versions for ${r.workspace}/${r.name}.` })
153
+ versions_by_repo.set(key, [r.head_version].filter(Boolean))
154
+ continue
155
+ }
156
+ let sorted = [...new Set(vs)].sort(cmp_version_desc)
157
+ if (sorted.length > MAX_VERSIONS_PER_REPO) {
158
+ warnings.push({ code: 'VERSIONS_TRUNCATED', message: `${r.workspace}/${r.name} has ${sorted.length} versions; scanning the newest ${MAX_VERSIONS_PER_REPO}.` })
159
+ sorted = sorted.slice(0, MAX_VERSIONS_PER_REPO)
160
+ }
161
+ versions_by_repo.set(key, sorted)
162
+ }
163
+ } else {
164
+ for (const [key, r] of repo_needs) versions_by_repo.set(key, [r.head_version].filter(Boolean))
165
+ }
166
+
167
+ // ── Stage 3: fetch manifests for EVERY candidate (repo, version) ─────
168
+ const manifest_needs = new Map()
169
+ for (const { cand } of pending) {
170
+ const key = repo_key(cand)
171
+ for (const v of (versions_by_repo.get(key) || [])) {
172
+ manifest_needs.set(`${key}@${v}`, { workspace: cand.workspace, name: cand.name, version: v })
173
+ }
174
+ }
113
175
  const manifest_by_key = new Map()
114
176
  const needs = [...manifest_needs.values()]
115
177
  if (needs.length) {
@@ -125,23 +187,55 @@ const run_funnel = (skills, deps, opts = {}) =>
125
187
  }
126
188
  }
127
189
 
128
- // ── finalize the hash/name candidates with their diffs ───────────────
190
+ // ── Stage 3.5: cross-version containment classification ──────────────
129
191
  for (const { skill, cand, via } of pending) {
130
- const m = manifest_by_key.get(key_of(cand))
131
- const diff = m ? diff_manifests(skill.files, m.files) : null
132
- const entry = {
133
- local: local_view(skill),
134
- match: match_view(cand),
135
- differing_files: diff ? diff.differing : null,
136
- similarity: diff ? diff.similarity : null,
192
+ const key = repo_key(cand)
193
+ const head = cand.version
194
+ let contained = null // { version, tree_sha, local_only } — EARLIEST contained
195
+ let closest = null // { version, tree_sha, diff } — best partial
196
+ for (const v of (versions_by_repo.get(key) || [])) { // newest first
197
+ const m = manifest_by_key.get(`${key}@${v}`)
198
+ if (!m) continue
199
+ const diff = diff_manifests(skill.files, m.files, { ignore: is_ignored_file })
200
+ // Contained = every FUNCTIONAL catalog file present locally, same SHA
201
+ // (no missing, no modified). Extra local files (diff.added) are dirt
202
+ // and don't block it. Report the EARLIEST contained version — where
203
+ // this content originated; a later metadata-only re-release ships the
204
+ // same content under a new number but doesn't redefine its identity.
205
+ // Iterating newest→oldest and overwriting leaves the oldest match.
206
+ if (diff.removed.length === 0 && diff.modified.length === 0) {
207
+ contained = { version: v, tree_sha: m.tree_sha, local_only: diff.added }
208
+ }
209
+ if (!closest || diff.similarity > closest.diff.similarity) closest = { version: v, tree_sha: m.tree_sha, diff }
137
210
  }
138
- if (via === 'hash') {
139
- // SKILL.md identical by construction → the difference is ancillary.
140
- buckets.near.push(entry)
141
- } else if (diff && diff.similarity >= threshold) {
142
- buckets.near.push(entry)
211
+
212
+ if (contained) {
213
+ const local_only = contained.local_only.filter(p => !is_ignored_file(p))
214
+ buckets.certified.push({
215
+ local: local_view(skill),
216
+ match: match_view({ workspace: cand.workspace, name: cand.name, version: contained.version, tree_sha: contained.tree_sha, is_source_of_truth: cand.is_source_of_truth, latest_version: head }),
217
+ ...(local_only.length ? { local_only_files: local_only } : {}),
218
+ })
219
+ continue
220
+ }
221
+
222
+ if (closest) {
223
+ const entry = {
224
+ local: local_view(skill),
225
+ match: match_view({ workspace: cand.workspace, name: cand.name, version: closest.version, tree_sha: closest.tree_sha, is_source_of_truth: cand.is_source_of_truth, latest_version: head }),
226
+ differing_files: closest.diff.differing,
227
+ similarity: closest.diff.similarity,
228
+ }
229
+ // A hash match (SKILL.md identical to head) stays `near` regardless of
230
+ // similarity; a name match needs to clear the threshold.
231
+ if (via === 'hash' || closest.diff.similarity >= threshold) buckets.near.push(entry)
232
+ else buckets.likely.push(entry)
143
233
  } else {
144
- buckets.likely.push(entry)
234
+ // No manifest resolved at all (every fetch failed). A hash match is
235
+ // still `near` (SKILL.md identical); a name match degrades to `likely`.
236
+ const entry = { local: local_view(skill), match: match_view(cand), differing_files: null, similarity: null }
237
+ if (via === 'hash') buckets.near.push(entry)
238
+ else buckets.likely.push(entry)
145
239
  }
146
240
  }
147
241
 
@@ -188,4 +282,4 @@ const semantic_match_view = (hit) => ({
188
282
  score: typeof hit.score === 'number' ? hit.score : null,
189
283
  })
190
284
 
191
- module.exports = { run_funnel, DEFAULT_THRESHOLD, MANIFEST_CHUNK }
285
+ module.exports = { run_funnel, DEFAULT_THRESHOLD, MANIFEST_CHUNK, MAX_VERSIONS_PER_REPO }
@@ -20,13 +20,23 @@ const skill = (over) => ({
20
20
  })
21
21
  const cand = (over) => ({ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote', skill_md_sha: 'h_skillmd', is_source_of_truth: true, ...over })
22
22
 
23
- // A deps double that records calls.
24
- const make_deps = ({ results = [], name_results = [], tree_results = [], manifests = [], semantic = null } = {}) => {
25
- const calls = { match: 0, manifests: 0, semantic: 0, blob: 0 }
23
+ // A deps double that records calls. `versions` is auto-derived from the
24
+ // provided manifests per repo (override with a { "ws/name": [vers] } map or a
25
+ // flat array) so a test just lists the version manifests it wants scanned.
26
+ const make_deps = ({ results = [], name_results = [], tree_results = [], manifests = [], versions = null, semantic = null } = {}) => {
27
+ const calls = { match: 0, manifests: 0, versions: 0, semantic: 0, blob: 0 }
28
+ const derive_versions = (ws, name) => {
29
+ if (Array.isArray(versions)) return versions
30
+ if (versions && typeof versions === 'object') return versions[`${String(ws).toLowerCase()}/${name}`] || versions[name] || []
31
+ return [...new Set(manifests
32
+ .filter(m => String(m.workspace).toLowerCase() === String(ws).toLowerCase() && m.name === name)
33
+ .map(m => m.version))]
34
+ }
26
35
  return {
27
36
  calls,
28
37
  match: async () => { calls.match++; return [null, { results, name_results, tree_results }] },
29
38
  manifests: async () => { calls.manifests++; return [null, { manifests, warnings: [] }] },
39
+ versions: async (ws, name) => { calls.versions++; return [null, derive_versions(ws, name)] },
30
40
  ...(semantic ? { semantic_search: async (q) => { calls.semantic++; return semantic(q) } } : {}),
31
41
  }
32
42
  }
@@ -71,15 +81,18 @@ test('name-only match with low similarity (SKILL.md differs) → likely', async
71
81
  })
72
82
 
73
83
  test('name-only match above the threshold → near', async () => {
74
- const local = skill({ skill_md_sha: 'h_local_only', files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'shared' }] })
84
+ // Above the threshold but NOT identical one functional file differs (2 of 3
85
+ // match → 0.67). A truly identical manifest would (correctly) certify instead.
86
+ const local = skill({ skill_md_sha: 'h_local_only', files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'shared' }, { path: 'b.md', blob_sha: 'b_local' }] })
75
87
  const deps = make_deps({
76
88
  name_results: [{ name: 'webapp-testing', candidates: [cand({ skill_md_sha: 'h_other', tree_sha: 't_remote' })] }],
77
89
  manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
78
- files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'shared' }] }],
90
+ files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'shared' }, { path: 'b.md', blob_sha: 'b_remote' }] }],
79
91
  })
80
92
  const [err, out] = await run_funnel([local], deps, { threshold: 0.5 })
81
93
  assert.equal(err, null)
82
94
  assert.equal(out.buckets.near.length, 1)
95
+ assert.deepEqual(out.buckets.near[0].differing_files, ['b.md'])
83
96
  })
84
97
 
85
98
  test('no hash/name match, semantic hit → possible', async () => {
@@ -189,6 +202,163 @@ test('a semantic search that REJECTS still lands the skill in unmatched (never s
189
202
  assert.ok(out.warnings.some(w => w.code === 'SEMANTIC_SEARCH_FAILED'))
190
203
  })
191
204
 
205
+ // ── Metadata files & cross-version containment (spec 260629-03 §4.9/§4.10) ───
206
+
207
+ test('functionally identical to head (only skill.json differs) → certified, no functional extras', async () => {
208
+ const local = skill({
209
+ files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'skill.json', blob_sha: 'b_sj_local' }],
210
+ })
211
+ const deps = make_deps({
212
+ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_remote' })] }],
213
+ manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
214
+ files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'skill.json', blob_sha: 'b_sj_remote' }] }],
215
+ })
216
+ const [err, out] = await run_funnel([local], deps)
217
+ assert.equal(err, null)
218
+ assert.equal(out.buckets.certified.length, 1, 'only skill.json differs → functional content identical → certified')
219
+ assert.equal(out.buckets.near.length, 0)
220
+ assert.equal(out.buckets.certified[0].match.version, '1.0.0')
221
+ assert.equal(out.buckets.certified[0].match.is_latest, true, 'matches head → is_latest')
222
+ assert.equal(out.buckets.certified[0].local_only_files, undefined, 'skill.json is ignored, not a functional extra')
223
+ })
224
+
225
+ test('CHANGELOG.md is also ignored — a CHANGELOG-only difference is still certified (case-insensitive)', async () => {
226
+ const local = skill({
227
+ files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'CHANGELOG.md', blob_sha: 'b_cl_local' }],
228
+ })
229
+ const deps = make_deps({
230
+ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_remote' })] }],
231
+ manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
232
+ files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'CHANGELOG.md', blob_sha: 'b_cl_remote' }] }],
233
+ })
234
+ const [err, out] = await run_funnel([local], deps)
235
+ assert.equal(err, null)
236
+ assert.equal(out.buckets.certified.length, 1)
237
+ assert.equal(out.buckets.certified[0].local_only_files, undefined)
238
+ })
239
+
240
+ test('a real (SKILL.md) difference is NOT certified even when skill.json also differs; differing_files excludes the ignored file', async () => {
241
+ // Name match (SKILL.md changed → different hash). skill.json ALSO differs, but
242
+ // it must not count: the reported difference is the real one (SKILL.md), and
243
+ // skill.json is filtered out of differing_files + the similarity denominator.
244
+ const local = skill({
245
+ skill_md_sha: 'h_local', tree_sha: 't_local',
246
+ files: [{ path: 'SKILL.md', blob_sha: 'x_local' }, { path: 'skill.json', blob_sha: 'sj_local' }, { path: 'a.md', blob_sha: 'shared' }],
247
+ })
248
+ const deps = make_deps({
249
+ name_results: [{ name: 'webapp-testing', candidates: [cand({ skill_md_sha: 'h_other', tree_sha: 't_remote' })] }],
250
+ manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
251
+ files: [{ path: 'SKILL.md', blob_sha: 'x_remote' }, { path: 'skill.json', blob_sha: 'sj_remote' }, { path: 'a.md', blob_sha: 'shared' }] }],
252
+ })
253
+ const [err, out] = await run_funnel([local], deps, { threshold: 0.5 })
254
+ assert.equal(err, null)
255
+ assert.equal(out.buckets.certified.length, 0, 'SKILL.md differs → real difference → not certified')
256
+ const entry = out.buckets.near[0] || out.buckets.likely[0]
257
+ assert.ok(entry, 'lands in near or likely')
258
+ assert.deepEqual(entry.differing_files, ['SKILL.md'], 'skill.json excluded from differing_files (it does not count)')
259
+ })
260
+
261
+ test('cross-version: local matches an OLDER version by SKILL.md + files → certified at that version, not head', async () => {
262
+ // Head 0.3.2 has a newer SKILL.md; the local content equals 0.3.1. It's a NAME
263
+ // match (local SKILL.md != head SKILL.md), so only the cross-version scan finds it.
264
+ const local = skill({
265
+ name: 'tiny-gemini-image', skill_md_sha: 'h_v031', tree_sha: 't_local',
266
+ files: [{ path: 'SKILL.md', blob_sha: 'md_v031' }, { path: 'references/x.md', blob_sha: 'ref_v031' }],
267
+ })
268
+ const deps = make_deps({
269
+ name_results: [{ name: 'tiny-gemini-image', candidates: [
270
+ { workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.2', tree_sha: 't_v032', skill_md_sha: 'md_v032', is_source_of_truth: true }] }],
271
+ manifests: [
272
+ { workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.2', tree_sha: 't_v032',
273
+ files: [{ path: 'SKILL.md', blob_sha: 'md_v032' }, { path: 'references/x.md', blob_sha: 'ref_v032' }, { path: 'skill.json', blob_sha: 'sj_032' }] },
274
+ { workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.1', tree_sha: 't_v031',
275
+ files: [{ path: 'SKILL.md', blob_sha: 'md_v031' }, { path: 'references/x.md', blob_sha: 'ref_v031' }, { path: 'skill.json', blob_sha: 'sj_031' }] },
276
+ { workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.0', tree_sha: 't_v030',
277
+ files: [{ path: 'SKILL.md', blob_sha: 'md_v030' }, { path: 'references/x.md', blob_sha: 'ref_v030' }, { path: 'skill.json', blob_sha: 'sj_030' }] },
278
+ ],
279
+ })
280
+ const [err, out] = await run_funnel([local], deps)
281
+ assert.equal(err, null)
282
+ assert.equal(out.buckets.certified.length, 1, 'local content == v0.3.1 → certified')
283
+ assert.equal(out.buckets.near.length + out.buckets.likely.length, 0)
284
+ assert.equal(out.buckets.certified[0].match.version, '0.3.1', 'pinned to the exact older version its content matches')
285
+ assert.equal(out.buckets.certified[0].match.latest_version, '0.3.2')
286
+ assert.equal(out.buckets.certified[0].match.is_latest, false)
287
+ })
288
+
289
+ test('when content matches several versions (metadata-only re-releases), certify the EARLIEST (origin)', async () => {
290
+ // Local content is functionally identical to BOTH 1.5.0 and 1.5.1 (1.5.1 only
291
+ // bumped CHANGELOG/skill.json). Report 1.5.0 — where the content originated.
292
+ const local = skill({
293
+ name: 'update-doc', skill_md_sha: 'h_md', tree_sha: 't_local',
294
+ files: [{ path: 'SKILL.md', blob_sha: 'md' }, { path: 'references/x.md', blob_sha: 'ref' }],
295
+ })
296
+ const deps = make_deps({
297
+ results: [{ skill_md_sha: 'h_md', candidates: [
298
+ { workspace: 'nicolasdao', name: 'update-doc', version: '1.9.0', tree_sha: 't_head', skill_md_sha: 'h_md', is_source_of_truth: true }] }],
299
+ manifests: [
300
+ { workspace: 'nicolasdao', name: 'update-doc', version: '1.5.1', tree_sha: 't_151',
301
+ files: [{ path: 'SKILL.md', blob_sha: 'md' }, { path: 'references/x.md', blob_sha: 'ref' }, { path: 'CHANGELOG.md', blob_sha: 'cl_151' }, { path: 'skill.json', blob_sha: 'sj_151' }] },
302
+ { workspace: 'nicolasdao', name: 'update-doc', version: '1.5.0', tree_sha: 't_150',
303
+ files: [{ path: 'SKILL.md', blob_sha: 'md' }, { path: 'references/x.md', blob_sha: 'ref' }, { path: 'CHANGELOG.md', blob_sha: 'cl_150' }, { path: 'skill.json', blob_sha: 'sj_150' }] },
304
+ ],
305
+ })
306
+ const [err, out] = await run_funnel([local], deps)
307
+ assert.equal(err, null)
308
+ assert.equal(out.buckets.certified.length, 1)
309
+ assert.equal(out.buckets.certified[0].match.version, '1.5.0', 'both 1.5.0 and 1.5.1 match → report the earliest (origin)')
310
+ assert.equal(out.buckets.certified[0].match.latest_version, '1.9.0')
311
+ assert.equal(out.buckets.certified[0].match.is_latest, false)
312
+ })
313
+
314
+ test('dirt tolerance: local has an extra __pycache__/*.pyc (and no skill.json) → certified, with local_only_files', async () => {
315
+ const local = skill({
316
+ name: 'init-doc', skill_md_sha: 'h_md', tree_sha: 't_local',
317
+ files: [
318
+ { path: 'SKILL.md', blob_sha: 'md1' },
319
+ { path: 'scripts/build.py', blob_sha: 'py1' },
320
+ { path: 'scripts/__pycache__/build.cpython-310.pyc', blob_sha: 'pyc1' }, // local dirt
321
+ ],
322
+ })
323
+ const deps = make_deps({
324
+ results: [{ skill_md_sha: 'h_md', candidates: [cand({ name: 'init-doc', tree_sha: 't_remote' })] }],
325
+ manifests: [{ workspace: 'anthropics', name: 'init-doc', version: '1.0.0', tree_sha: 't_remote',
326
+ files: [{ path: 'SKILL.md', blob_sha: 'md1' }, { path: 'scripts/build.py', blob_sha: 'py1' }, { path: 'skill.json', blob_sha: 'sj1' }] }],
327
+ })
328
+ const [err, out] = await run_funnel([local], deps)
329
+ assert.equal(err, null)
330
+ assert.equal(out.buckets.certified.length, 1, 'catalog functional files ⊆ local → certified despite the extra .pyc')
331
+ assert.equal(out.buckets.near.length, 0)
332
+ assert.deepEqual(out.buckets.certified[0].local_only_files, ['scripts/__pycache__/build.cpython-310.pyc'])
333
+ })
334
+
335
+ test('a local copy MISSING one of the version files is NOT certified (containment fails)', async () => {
336
+ const local = skill({
337
+ name: 'partial', skill_md_sha: 'h_md', tree_sha: 't_local',
338
+ files: [{ path: 'SKILL.md', blob_sha: 'md1' }], // missing references/x.md the version ships
339
+ })
340
+ const deps = make_deps({
341
+ results: [{ skill_md_sha: 'h_md', candidates: [cand({ name: 'partial', tree_sha: 't_remote' })] }],
342
+ manifests: [{ workspace: 'anthropics', name: 'partial', version: '1.0.0', tree_sha: 't_remote',
343
+ files: [{ path: 'SKILL.md', blob_sha: 'md1' }, { path: 'references/x.md', blob_sha: 'ref1' }, { path: 'skill.json', blob_sha: 'sj1' }] }],
344
+ })
345
+ const [err, out] = await run_funnel([local], deps)
346
+ assert.equal(err, null)
347
+ assert.equal(out.buckets.certified.length, 0, 'missing a functional file → not fully contained → not certified')
348
+ assert.equal(out.buckets.near.length + out.buckets.likely.length, 1)
349
+ })
350
+
351
+ test('diff_manifests with an ignore predicate excludes ignored files from the functional diff + similarity', () => {
352
+ const d = diff_manifests(
353
+ [{ path: 'SKILL.md', blob_sha: 'a' }, { path: 'skill.json', blob_sha: 'v1' }, { path: 'keep', blob_sha: 'k' }],
354
+ [{ path: 'SKILL.md', blob_sha: 'a' }, { path: 'skill.json', blob_sha: 'v2' }, { path: 'keep', blob_sha: 'k' }],
355
+ { ignore: (p) => p.toLowerCase() === 'skill.json' },
356
+ )
357
+ assert.deepEqual(d.differing, [], 'skill.json excluded → no functional differences')
358
+ assert.deepEqual(d.ignored_differing, ['skill.json'])
359
+ assert.equal(d.similarity, 1, 'similarity computed over functional files only (2/2 identical)')
360
+ })
361
+
192
362
  test('diff_manifests classifies identical / modified / added / removed', () => {
193
363
  const d = diff_manifests(
194
364
  [{ path: 'SKILL.md', blob_sha: 'a' }, { path: 'keep', blob_sha: 'k' }, { path: 'local-only', blob_sha: 'z' }],
@@ -34,7 +34,7 @@ test('drift set → appends a SKILLS_UPDATE_AVAILABLE warning without touching d
34
34
  assert.strictEqual(env.warnings[0].code, 'SKILLS_UPDATE_AVAILABLE')
35
35
  assert.match(env.warnings[0].message, /2 installed skill\(s\) behind the registry/)
36
36
  assert.match(env.warnings[0].message, /acme\/x, acme\/y/)
37
- assert.match(env.warnings[0].message, /npx happyskills update --all/)
37
+ assert.match(env.warnings[0].message, /npx happyskills update acme\/x acme\/y/)
38
38
  })
39
39
  })
40
40
 
@@ -44,6 +44,16 @@ const write_scope = (key, outdated) => {
44
44
  }
45
45
  }
46
46
 
47
+ // Selects which locked skills to check for registry drift: EVERY installed skill,
48
+ // not just __root__ entries. A bundled family satellite (e.g. happyskills-help) is
49
+ // pulled in as a transitive dependency of core — its lock entry reads
50
+ // requested_by:['happyskillsai/happyskills'], NOT ['__root__'] — so a __root__-only
51
+ // filter silently never nudged about it even when it fell behind. Mirrors the
52
+ // "check every installed skill" enumeration in commands/check.js. A null value is a
53
+ // removed-skill tombstone — skip it. Extracted as a pure function for unit testing.
54
+ const select_drift_candidates = (skills) =>
55
+ Object.entries(skills || {}).filter(([, d]) => d && typeof d === 'object')
56
+
47
57
  // Background refresh — NOT awaited, all errors swallowed. Reads the lock directly
48
58
  // (NOT via lock/reader.read_lock, which prints a stderr warning on version mismatch
49
59
  // — unwanted noise from a silent background task), batch-checks root skills against
@@ -60,8 +70,7 @@ const refresh_cache = (key, opts) => {
60
70
  const lock_path = lock_file_path(lock_root(opts.is_global, opts.project_root))
61
71
  const [, lock_data] = await read_json(lock_path)
62
72
  const skills = (lock_data && lock_data.skills) || {}
63
- const candidates = Object.entries(skills)
64
- .filter(([, d]) => d && Array.isArray(d.requested_by) && d.requested_by.includes('__root__'))
73
+ const candidates = select_drift_candidates(skills)
65
74
 
66
75
  if (candidates.length === 0) { write_scope(key, []); return }
67
76
 
@@ -140,11 +149,17 @@ const format_skill_drift = (verdict) => {
140
149
  const items = verdict.outdated
141
150
  const names = items.slice(0, 3).map(s => s.skill).join(', ')
142
151
  const more = items.length > 3 ? `, +${items.length - 3} more` : ''
143
- let cmd
144
- if (verdict.has_global && verdict.has_local) cmd = 'npx happyskills update --all (and add -g for the global ones)'
145
- else if (verdict.has_global) cmd = 'npx happyskills update --all -g'
146
- else cmd = 'npx happyskills update --all'
147
- return `${items.length} installed skill(s) behind the registry (${names}${more}). Run: ${cmd}`
152
+ // Name the outdated skills explicitly rather than suggesting `update --all`.
153
+ // `update --all` only re-checks __root__ skills, so it can never refresh a bundled
154
+ // satellite installed as a transitive dependency — but `update <owner/name>` fixes
155
+ // ANY locked skill. Group by scope: a global skill needs -g, and one update run
156
+ // writes a single scope. Items with no scope default to local.
157
+ const local = items.filter(i => i.scope !== 'global').map(i => i.skill)
158
+ const global = items.filter(i => i.scope === 'global').map(i => i.skill)
159
+ const cmds = []
160
+ if (local.length) cmds.push(`npx happyskills update ${local.join(' ')}`)
161
+ if (global.length) cmds.push(`npx happyskills update ${global.join(' ')} -g`)
162
+ return `${items.length} installed skill(s) behind the registry (${names}${more}). Run: ${cmds.join(' && ')}`
148
163
  }
149
164
 
150
- module.exports = { check_skills_for_update, format_skill_drift, get_cache_path, scope_key }
165
+ module.exports = { check_skills_for_update, format_skill_drift, select_drift_candidates, get_cache_path, scope_key }
@@ -4,7 +4,7 @@ const fs = require('fs')
4
4
  const os = require('os')
5
5
  const path = require('path')
6
6
 
7
- const { check_skills_for_update, format_skill_drift, get_cache_path, scope_key } = require('./skill_update_check')
7
+ const { check_skills_for_update, format_skill_drift, select_drift_candidates, get_cache_path, scope_key } = require('./skill_update_check')
8
8
 
9
9
  const PROJECT_ROOT = '/proj'
10
10
  const PKEY = scope_key({ is_global: false, project_root: PROJECT_ROOT })
@@ -53,7 +53,7 @@ test('project-only drift → verdict flagged local, no -g in the message', () =>
53
53
  assert.strictEqual(v.outdated.length, 1)
54
54
  assert.strictEqual(v.has_local, true)
55
55
  assert.strictEqual(v.has_global, false)
56
- assert.match(format_skill_drift(v), /update --all$/)
56
+ assert.match(format_skill_drift(v), /Run: npx happyskills update acme\/x$/)
57
57
  })
58
58
  })
59
59
 
@@ -64,7 +64,7 @@ test('global-only drift → verdict flagged global, message uses -g', () => {
64
64
  assert.ok(v)
65
65
  assert.strictEqual(v.has_local, false)
66
66
  assert.strictEqual(v.has_global, true)
67
- assert.match(format_skill_drift(v), /update --all -g$/)
67
+ assert.match(format_skill_drift(v), /Run: npx happyskills update happyskillsai\/happyskills -g$/)
68
68
  })
69
69
  })
70
70
 
@@ -75,7 +75,9 @@ test('drift in BOTH scopes → merged, both flags set, message mentions -g for g
75
75
  assert.strictEqual(v.outdated.length, 2)
76
76
  assert.strictEqual(v.has_local, true)
77
77
  assert.strictEqual(v.has_global, true)
78
- assert.match(format_skill_drift(v), /add -g for the global ones/)
78
+ const msg = format_skill_drift(v)
79
+ assert.match(msg, /npx happyskills update acme\/x/)
80
+ assert.match(msg, /npx happyskills update happyskillsai\/happyskills -g/)
79
81
  })
80
82
  })
81
83
 
@@ -116,3 +118,38 @@ test('localhost:0 test API sentinel short-circuits to null', () => {
116
118
  assert.strictEqual(check_skills_for_update({ project_root: PROJECT_ROOT }), null)
117
119
  })
118
120
  })
121
+
122
+ // ─── Regression: the passive check must watch the WHOLE installed family ─────────
123
+ // A bundled family satellite (design/publish/sync/search/help) is installed as a
124
+ // transitive dependency of core, so its lock entry is requested_by:[core], NOT
125
+ // [__root__]. A __root__-only enumeration silently never nudged about it even when it
126
+ // fell behind the registry — the gap this suite pins shut. See docs/cli-overview.md § 3.
127
+
128
+ test('select_drift_candidates watches transitive dependencies, not just __root__ skills', () => {
129
+ const skills = {
130
+ 'happyskillsai/happyskills': { version: '2.5.4', base_commit: 'aaa', requested_by: ['__root__'] },
131
+ 'happyskillsai/happyskills-help': { version: '0.8.2', base_commit: 'bbb', requested_by: ['happyskillsai/happyskills'] },
132
+ }
133
+ const names = select_drift_candidates(skills).map(([n]) => n)
134
+ assert.ok(names.includes('happyskillsai/happyskills-help'),
135
+ 'a bundled satellite pulled in as a transitive dependency must still be checked for updates')
136
+ })
137
+
138
+ test('select_drift_candidates keeps __root__ skills and skips null (removed-skill) tombstones', () => {
139
+ const skills = {
140
+ 'acme/root': { version: '1.0.0', requested_by: ['__root__'] },
141
+ 'acme/removed': null,
142
+ }
143
+ const names = select_drift_candidates(skills).map(([n]) => n)
144
+ assert.ok(names.includes('acme/root'))
145
+ assert.ok(!names.includes('acme/removed'))
146
+ })
147
+
148
+ test('format_skill_drift names the skills (works for transitive deps), never suggests update --all', () => {
149
+ // `update --all` only re-checks __root__ skills, so it can never refresh a bundled
150
+ // satellite. The remedy must name the skill: `update <owner/name>` fixes ANY locked skill.
151
+ const verdict = { outdated: [{ skill: 'happyskillsai/happyskills-help', installed: '0.8.2', latest: '0.9.0', scope: 'local' }], has_local: true, has_global: false }
152
+ const msg = format_skill_drift(verdict)
153
+ assert.match(msg, /npx happyskills update happyskillsai\/happyskills-help/)
154
+ assert.doesNotMatch(msg, /update --all/)
155
+ })