happyskills 1.14.0 → 1.16.0

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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.16.0] - 2026-07-01
11
+
12
+ ### Changed
13
+
14
+ - 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.)
15
+
16
+ ## [1.15.0] - 2026-06-30
17
+
18
+ ### Changed
19
+
20
+ - Make `happyskills match` **version-precise**: it now matches each local skill against **all** published versions of a skill, not just the latest. A local copy that is byte-identical to an *older* release is reported as `certified` at that exact version (the result carries `match.version`, `match.latest_version`, and `match.is_latest`) — instead of being diffed against head and misreported as `likely`/drift just because you're a few versions behind. The text output gains an "identical to an older published version (you are behind)" line listing each such skill as `name → owner/skill @ <version> (latest <latest>)`. Mechanically, the match funnel now also sends the whole-skill `tree_sha`s in the same batched `POST /repos:match` call and certifies on a cross-version tree match (with a fallback to the previous head-only behavior for older API servers). Requires API ≥ 5.18.0 (the `tree_shas` axis on `POST /repos:match`). (Spec 260629-03 §4.8.)
21
+
10
22
  ## [1.14.0] - 2026-06-30
11
23
 
12
24
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
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]
@@ -214,11 +226,14 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
214
226
  })
215
227
 
216
228
  // POST /repos:match — catalog matching for `happyskills match` (spec 260629-03).
217
- // ONE batch call for the whole folder's hashes + names. Returns
218
- // { results: [{ skill_md_sha, candidates }], name_results: [{ name, candidates }] }
219
- // (two keys, so the client's single-key unwrap leaves it intact).
220
- const match = (hashes, names) => catch_errors('Catalog match lookup failed', async () => {
221
- const [errors, data] = await client.post('/repos:match', { hashes, names: names || [] })
229
+ // ONE batch call for the whole folder's hashes + names + tree shas. Returns
230
+ // { results: [{ skill_md_sha, candidates }], name_results: [{ name, candidates }],
231
+ // tree_results: [{ tree_sha, candidates }] } — multi-key, so the client's
232
+ // single-key unwrap leaves it intact. `tree_results` (spec §4.8) matches the
233
+ // whole-skill content address across ALL versions, so a local copy identical to
234
+ // an older release is pinned to that exact version instead of drifting vs head.
235
+ const match = (hashes, names, tree_shas) => catch_errors('Catalog match lookup failed', async () => {
236
+ const [errors, data] = await client.post('/repos:match', { hashes, names: names || [], tree_shas: tree_shas || [] })
222
237
  if (errors) throw errors[errors.length - 1]
223
238
  return data
224
239
  })
@@ -235,4 +250,4 @@ const manifests = (repo_versions) => catch_errors('Manifests lookup failed', asy
235
250
  }
236
251
  })
237
252
 
238
- 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,7 +130,31 @@ 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`)
134
+
135
+ // A certified match to an OLDER version is still an exact match — surface the
136
+ // version gap so the principal knows it is the real skill, just behind.
137
+ const behind = data.certified.filter(en => en.match && en.match.latest_version && en.match.is_latest === false)
138
+ if (behind.length) {
139
+ print_info('\nCertified to an older published version (you are behind):')
140
+ for (const en of behind) {
141
+ const where = en.match ? `${en.match.workspace}/${en.match.name}` : '(catalog)'
142
+ print_info(` ${en.local.name} → ${where} @ ${en.match.version} (latest ${en.match.latest_version})`)
143
+ }
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
+ }
133
158
 
134
159
  if (data.near.length) {
135
160
  print_info('\nSkills with small differences:')
@@ -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
  }
@@ -90,7 +90,13 @@ const run_cli = (args, env_override = {}) => new Promise((resolve) => {
90
90
  const has_mode_flag = args.includes('--json') || args.includes('--text')
91
91
  const final_args = has_mode_flag ? args : [...args, '--text']
92
92
  const child = spawn(NODE, [CLI, ...final_args], {
93
- env: { ...process.env, NO_COLOR: '1', CI: '', ...env_override },
93
+ // Isolate the CLI config dir so the spawned binary never reads the
94
+ // developer's real ~/.config/happyskills credentials. A stored (expired)
95
+ // token would otherwise make an authed command (e.g. a search POST)
96
+ // attempt a token refresh against the stub's missing /auth/refresh route
97
+ // and fail with NETWORK_ERROR — a machine-state-dependent flake. A test
98
+ // that needs a specific config dir overrides this via env_override (last).
99
+ env: { ...process.env, NO_COLOR: '1', CI: '', XDG_CONFIG_HOME: make_tmp(), ...env_override },
94
100
  })
95
101
  let stdout = ''
96
102
  let stderr = ''
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,11 +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,
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.
77
+ latest_version: c.latest_version || null,
78
+ is_latest: c.latest_version ? c.version === c.latest_version : null,
45
79
  })
46
80
 
47
- // 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.
48
84
  // opts: { threshold, concurrency }
49
85
  const run_funnel = (skills, deps, opts = {}) =>
50
86
  catch_errors('Match funnel failed', async () => {
@@ -58,46 +94,84 @@ const run_funnel = (skills, deps, opts = {}) =>
58
94
  // ── Stage 1: one batched match for the whole folder ──────────────────
59
95
  const hashes = [...new Set(skills.map(s => s.skill_md_sha).filter(Boolean))]
60
96
  const names = [...new Set(skills.map(s => s.name).filter(Boolean))]
61
- const [m_err, match_data] = await deps.match(hashes, names)
97
+ const tree_shas = [...new Set(skills.map(s => s.tree_sha).filter(Boolean))]
98
+ const [m_err, match_data] = await deps.match(hashes, names, tree_shas)
62
99
  if (m_err) throw e('Stage 1 /repos:match failed', m_err)
63
100
 
64
101
  const by_hash = new Map((match_data.results || []).map(r => [r.skill_md_sha, r.candidates || []]))
65
102
  const by_name = new Map((match_data.name_results || []).map(r => [String(r.name).toLowerCase(), r.candidates || []]))
103
+ const by_tree = new Map((match_data.tree_results || []).map(r => [r.tree_sha, r.candidates || []]))
66
104
 
67
- // ── Stage 2: classify; collect the narrowed manifest set ─────────────
68
- const pending = [] // needs a manifest diff
69
- const none = [] // no hash/name match → semantic stage
70
- 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 }
71
109
 
72
110
  for (const skill of skills) {
111
+ const tree_cands = (skill.tree_sha && by_tree.get(skill.tree_sha)) || []
73
112
  const hash_cands = (skill.skill_md_sha && by_hash.get(skill.skill_md_sha)) || []
74
113
  const name_cands = by_name.get(String(skill.name || '').toLowerCase()) || []
75
114
 
76
- // Certified = tree SHA equal, by the spec's definition regardless of
77
- // whether the candidate was found by hash or name. (A SKILL.md-less
78
- // skill has a null hash but can still be byte-identical to a name
79
- // match; hash candidates are preferred since they are checked first.)
80
- const certified = [...hash_cands, ...name_cands].find(c => c.tree_sha === skill.tree_sha)
81
- if (certified) {
82
- 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) })
83
121
  continue
84
122
  }
85
- if (hash_cands.length) {
86
- const sot = hash_cands[0] // API returns source-of-truth first
87
- manifest_needs.set(key_of(sot), { workspace: sot.workspace, name: sot.name, version: sot.version })
88
- pending.push({ skill, cand: sot, via: 'hash' })
89
- continue
90
- }
91
- if (name_cands.length) {
92
- const sot = name_cands[0]
93
- manifest_needs.set(key_of(sot), { workspace: sot.workspace, name: sot.name, version: sot.version })
94
- 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 })
95
135
  continue
96
136
  }
97
137
  none.push(skill)
98
138
  }
99
139
 
100
- // ── 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
+ }
101
175
  const manifest_by_key = new Map()
102
176
  const needs = [...manifest_needs.values()]
103
177
  if (needs.length) {
@@ -113,23 +187,55 @@ const run_funnel = (skills, deps, opts = {}) =>
113
187
  }
114
188
  }
115
189
 
116
- // ── finalize the hash/name candidates with their diffs ───────────────
190
+ // ── Stage 3.5: cross-version containment classification ──────────────
117
191
  for (const { skill, cand, via } of pending) {
118
- const m = manifest_by_key.get(key_of(cand))
119
- const diff = m ? diff_manifests(skill.files, m.files) : null
120
- const entry = {
121
- local: local_view(skill),
122
- match: match_view(cand),
123
- differing_files: diff ? diff.differing : null,
124
- 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 }
125
210
  }
126
- if (via === 'hash') {
127
- // SKILL.md identical by construction → the difference is ancillary.
128
- buckets.near.push(entry)
129
- } else if (diff && diff.similarity >= threshold) {
130
- 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)
131
233
  } else {
132
- 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)
133
239
  }
134
240
  }
135
241
 
@@ -176,4 +282,4 @@ const semantic_match_view = (hit) => ({
176
282
  score: typeof hit.score === 'number' ? hit.score : null,
177
283
  })
178
284
 
179
- 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 = [], 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
- match: async () => { calls.match++; return [null, { results, name_results }] },
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 () => {
@@ -107,6 +120,52 @@ test('Stage 1 is ONE batched call regardless of skill count (no N+1)', async ()
107
120
  assert.equal(deps.calls.match, 1, 'the whole folder resolves in one /repos:match call')
108
121
  })
109
122
 
123
+ // ── Tier 1: cross-version tree probe (spec 260629-03 §4.8) ───────────────────
124
+
125
+ test('local tree byte-identical to an OLDER published version → certified at that exact version', async () => {
126
+ // HEAD is 0.3.2 (a DIFFERENT tree — this is the drift the bug surfaced); the
127
+ // local copy is byte-identical to 0.3.0. The cross-version tree probe must
128
+ // pin it to 0.3.0 and certify it — NOT diff it against head and call it likely.
129
+ const local = skill({ name: 'tiny-gemini-image', skill_md_sha: 'h_old', tree_sha: 't_v030' })
130
+ const deps = make_deps({
131
+ // hash/name discovery still points at HEAD (head's tree differs from local)
132
+ name_results: [{ name: 'tiny-gemini-image', candidates: [cand({ name: 'tiny-gemini-image', version: '0.3.2', tree_sha: 't_v032', skill_md_sha: 'h_head' })] }],
133
+ tree_results: [{ tree_sha: 't_v030', candidates: [
134
+ { workspace: 'nicolasdao', name: 'tiny-gemini-image', version: '0.3.0', tree_sha: 't_v030', skill_md_sha: 'h_old', is_source_of_truth: true, latest_version: '0.3.2' },
135
+ ] }],
136
+ })
137
+ const [err, out] = await run_funnel([local], deps)
138
+ assert.equal(err, null)
139
+ assert.equal(out.buckets.certified.length, 1, 'an older byte-identical version is certified, not likely')
140
+ assert.equal(out.buckets.likely.length, 0)
141
+ assert.equal(out.buckets.near.length, 0)
142
+ assert.equal(out.buckets.certified[0].match.version, '0.3.0', 'pinned to the EXACT matching version, not head')
143
+ assert.equal(out.buckets.certified[0].match.latest_version, '0.3.2', 'carries the latest version so the caller can show "behind by"')
144
+ assert.equal(out.buckets.certified[0].match.is_latest, false)
145
+ assert.equal(deps.calls.manifests, 0, 'an exact tree match needs no manifest diff')
146
+ })
147
+
148
+ test('the local tree probe is sent in the SAME batched match call (no extra round-trip)', async () => {
149
+ const deps = make_deps({ tree_results: [{ tree_sha: 't_local', candidates: [
150
+ { workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_local', is_source_of_truth: true, latest_version: '1.0.0' },
151
+ ] }] })
152
+ const [err, out] = await run_funnel([skill(), skill({ dir: '/b' })], deps)
153
+ assert.equal(err, null)
154
+ assert.equal(deps.calls.match, 1, 'hashes + names + tree_shas all ride one /repos:match call')
155
+ assert.equal(out.buckets.certified.length, 2)
156
+ assert.equal(out.buckets.certified[0].match.is_latest, true, 'matching head is is_latest:true')
157
+ })
158
+
159
+ test('backward-compat: a server with no tree_results still certifies a head-identical skill', async () => {
160
+ // Older server returns no tree_results; the funnel falls back to head tree
161
+ // equality from the hash/name candidate so existing behavior is preserved.
162
+ const deps = make_deps({ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_local' })] }] })
163
+ const [err, out] = await run_funnel([skill()], deps)
164
+ assert.equal(err, null)
165
+ assert.equal(out.buckets.certified.length, 1)
166
+ assert.equal(out.buckets.certified[0].match.version, '1.0.0')
167
+ })
168
+
110
169
  test('a failed manifest batch becomes a warning, not a thrown error', async () => {
111
170
  const deps = {
112
171
  calls: {},
@@ -143,6 +202,163 @@ test('a semantic search that REJECTS still lands the skill in unmatched (never s
143
202
  assert.ok(out.warnings.some(w => w.code === 'SEMANTIC_SEARCH_FAILED'))
144
203
  })
145
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
+
146
362
  test('diff_manifests classifies identical / modified / added / removed', () => {
147
363
  const d = diff_manifests(
148
364
  [{ path: 'SKILL.md', blob_sha: 'a' }, { path: 'keep', blob_sha: 'k' }, { path: 'local-only', blob_sha: 'z' }],