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 +6 -0
- package/package.json +1 -1
- package/src/api/repos.js +8 -5
- package/src/commands/match.js +11 -0
- package/src/integration/api_envelope.test.js +7 -1
- package/src/match/funnel.js +18 -6
- package/src/match/funnel.test.js +48 -2
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
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
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
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
|
})
|
package/src/commands/match.js
CHANGED
|
@@ -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
|
-
|
|
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/funnel.js
CHANGED
|
@@ -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
|
|
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 =
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
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
|
package/src/match/funnel.test.js
CHANGED
|
@@ -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: {},
|