happyskills 1.14.0 → 1.15.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,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.15.0] - 2026-06-30
11
+
12
+ ### Changed
13
+
14
+ - 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.)
15
+
10
16
  ## [1.14.0] - 2026-06-30
11
17
 
12
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.14.0",
3
+ "version": "1.15.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
@@ -214,11 +214,14 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
214
214
  })
215
215
 
216
216
  // 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 || [] })
217
+ // ONE batch call for the whole folder's hashes + names + tree shas. Returns
218
+ // { results: [{ skill_md_sha, candidates }], name_results: [{ name, candidates }],
219
+ // tree_results: [{ tree_sha, candidates }] } — multi-key, so the client's
220
+ // single-key unwrap leaves it intact. `tree_results` (spec §4.8) matches the
221
+ // whole-skill content address across ALL versions, so a local copy identical to
222
+ // an older release is pinned to that exact version instead of drifting vs head.
223
+ const match = (hashes, names, tree_shas) => catch_errors('Catalog match lookup failed', async () => {
224
+ const [errors, data] = await client.post('/repos:match', { hashes, names: names || [], tree_shas: tree_shas || [] })
222
225
  if (errors) throw errors[errors.length - 1]
223
226
  return data
224
227
  })
@@ -131,6 +131,17 @@ const render_text = (data, warnings) => {
131
131
  print_info(`Scanned ${s.total} skill${s.total === 1 ? '' : 's'}.`)
132
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
133
 
134
+ // A certified match to an OLDER version is still an exact match — surface the
135
+ // version gap so the principal knows it is the real skill, just behind.
136
+ const behind = data.certified.filter(en => en.match && en.match.latest_version && en.match.is_latest === false)
137
+ if (behind.length) {
138
+ print_info('\nIdentical to an older published version (you are behind):')
139
+ for (const en of behind) {
140
+ const where = en.match ? `${en.match.workspace}/${en.match.name}` : '(catalog)'
141
+ print_info(` ${en.local.name} → ${where} @ ${en.match.version} (latest ${en.match.latest_version})`)
142
+ }
143
+ }
144
+
134
145
  if (data.near.length) {
135
146
  print_info('\nSkills with small differences:')
136
147
  for (const en of data.near) {
@@ -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 = ''
@@ -42,6 +42,11 @@ const match_view = (c) => ({
42
42
  version: c.version,
43
43
  tree_sha: c.tree_sha,
44
44
  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).
48
+ latest_version: c.latest_version || null,
49
+ is_latest: c.latest_version ? c.version === c.latest_version : null,
45
50
  })
46
51
 
47
52
  // deps: { match(hashes,names), manifests(repo_versions), semantic_search?(query) }
@@ -56,13 +61,18 @@ const run_funnel = (skills, deps, opts = {}) =>
56
61
  if (!skills.length) return { buckets, warnings }
57
62
 
58
63
  // ── 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.
59
67
  const hashes = [...new Set(skills.map(s => s.skill_md_sha).filter(Boolean))]
60
68
  const names = [...new Set(skills.map(s => s.name).filter(Boolean))]
61
- const [m_err, match_data] = await deps.match(hashes, names)
69
+ const tree_shas = [...new Set(skills.map(s => s.tree_sha).filter(Boolean))]
70
+ const [m_err, match_data] = await deps.match(hashes, names, tree_shas)
62
71
  if (m_err) throw e('Stage 1 /repos:match failed', m_err)
63
72
 
64
73
  const by_hash = new Map((match_data.results || []).map(r => [r.skill_md_sha, r.candidates || []]))
65
74
  const by_name = new Map((match_data.name_results || []).map(r => [String(r.name).toLowerCase(), r.candidates || []]))
75
+ const by_tree = new Map((match_data.tree_results || []).map(r => [r.tree_sha, r.candidates || []]))
66
76
 
67
77
  // ── Stage 2: classify; collect the narrowed manifest set ─────────────
68
78
  const pending = [] // needs a manifest diff
@@ -70,14 +80,16 @@ const run_funnel = (skills, deps, opts = {}) =>
70
80
  const manifest_needs = new Map()
71
81
 
72
82
  for (const skill of skills) {
83
+ const tree_cands = (skill.tree_sha && by_tree.get(skill.tree_sha)) || []
73
84
  const hash_cands = (skill.skill_md_sha && by_hash.get(skill.skill_md_sha)) || []
74
85
  const name_cands = by_name.get(String(skill.name || '').toLowerCase()) || []
75
86
 
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)
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)
81
93
  if (certified) {
82
94
  buckets.certified.push({ local: local_view(skill), match: match_view(certified) })
83
95
  continue
@@ -21,11 +21,11 @@ const skill = (over) => ({
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
23
  // A deps double that records calls.
24
- const make_deps = ({ results = [], name_results = [], manifests = [], semantic = null } = {}) => {
24
+ const make_deps = ({ results = [], name_results = [], tree_results = [], manifests = [], semantic = null } = {}) => {
25
25
  const calls = { match: 0, manifests: 0, semantic: 0, blob: 0 }
26
26
  return {
27
27
  calls,
28
- match: async () => { calls.match++; return [null, { results, name_results }] },
28
+ match: async () => { calls.match++; return [null, { results, name_results, tree_results }] },
29
29
  manifests: async () => { calls.manifests++; return [null, { manifests, warnings: [] }] },
30
30
  ...(semantic ? { semantic_search: async (q) => { calls.semantic++; return semantic(q) } } : {}),
31
31
  }
@@ -107,6 +107,52 @@ test('Stage 1 is ONE batched call regardless of skill count (no N+1)', async ()
107
107
  assert.equal(deps.calls.match, 1, 'the whole folder resolves in one /repos:match call')
108
108
  })
109
109
 
110
+ // ── Tier 1: cross-version tree probe (spec 260629-03 §4.8) ───────────────────
111
+
112
+ test('local tree byte-identical to an OLDER published version → certified at that exact version', async () => {
113
+ // HEAD is 0.3.2 (a DIFFERENT tree — this is the drift the bug surfaced); the
114
+ // local copy is byte-identical to 0.3.0. The cross-version tree probe must
115
+ // pin it to 0.3.0 and certify it — NOT diff it against head and call it likely.
116
+ const local = skill({ name: 'tiny-gemini-image', skill_md_sha: 'h_old', tree_sha: 't_v030' })
117
+ const deps = make_deps({
118
+ // hash/name discovery still points at HEAD (head's tree differs from local)
119
+ 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' })] }],
120
+ tree_results: [{ tree_sha: 't_v030', candidates: [
121
+ { 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' },
122
+ ] }],
123
+ })
124
+ const [err, out] = await run_funnel([local], deps)
125
+ assert.equal(err, null)
126
+ assert.equal(out.buckets.certified.length, 1, 'an older byte-identical version is certified, not likely')
127
+ assert.equal(out.buckets.likely.length, 0)
128
+ assert.equal(out.buckets.near.length, 0)
129
+ assert.equal(out.buckets.certified[0].match.version, '0.3.0', 'pinned to the EXACT matching version, not head')
130
+ assert.equal(out.buckets.certified[0].match.latest_version, '0.3.2', 'carries the latest version so the caller can show "behind by"')
131
+ assert.equal(out.buckets.certified[0].match.is_latest, false)
132
+ assert.equal(deps.calls.manifests, 0, 'an exact tree match needs no manifest diff')
133
+ })
134
+
135
+ test('the local tree probe is sent in the SAME batched match call (no extra round-trip)', async () => {
136
+ const deps = make_deps({ tree_results: [{ tree_sha: 't_local', candidates: [
137
+ { workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_local', is_source_of_truth: true, latest_version: '1.0.0' },
138
+ ] }] })
139
+ const [err, out] = await run_funnel([skill(), skill({ dir: '/b' })], deps)
140
+ assert.equal(err, null)
141
+ assert.equal(deps.calls.match, 1, 'hashes + names + tree_shas all ride one /repos:match call')
142
+ assert.equal(out.buckets.certified.length, 2)
143
+ assert.equal(out.buckets.certified[0].match.is_latest, true, 'matching head is is_latest:true')
144
+ })
145
+
146
+ test('backward-compat: a server with no tree_results still certifies a head-identical skill', async () => {
147
+ // Older server returns no tree_results; the funnel falls back to head tree
148
+ // equality from the hash/name candidate so existing behavior is preserved.
149
+ const deps = make_deps({ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_local' })] }] })
150
+ const [err, out] = await run_funnel([skill()], deps)
151
+ assert.equal(err, null)
152
+ assert.equal(out.buckets.certified.length, 1)
153
+ assert.equal(out.buckets.certified[0].match.version, '1.0.0')
154
+ })
155
+
110
156
  test('a failed manifest batch becomes a warning, not a thrown error', async () => {
111
157
  const deps = {
112
158
  calls: {},