happyskills 1.13.0 → 1.14.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/src/constants.js CHANGED
@@ -51,6 +51,7 @@ const COMMANDS = [
51
51
  'visibility',
52
52
  'list',
53
53
  'search',
54
+ 'match',
54
55
  'resolve',
55
56
  'star',
56
57
  'unstar',
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+ // Manifest diff (spec 260629-03 §4.7). Joins a local manifest against a catalog
3
+ // manifest on `path` with ZERO blob downloads — both sides are just
4
+ // [{ path, blob_sha }] lists. The differing-file list is the load-bearing
5
+ // artifact (not the single similarity %): it's what tells the principal exactly
6
+ // what changed (e.g. "only LICENSE differs").
7
+
8
+ // local_files / catalog_files: [{ path, blob_sha }]
9
+ const diff_manifests = (local_files, catalog_files) => {
10
+ const local = new Map((local_files || []).map(f => [f.path, f.blob_sha]))
11
+ const catalog = new Map((catalog_files || []).map(f => [f.path, f.blob_sha]))
12
+ const all = new Set([...local.keys(), ...catalog.keys()])
13
+
14
+ const identical = []
15
+ const modified = []
16
+ const added = [] // present locally, absent in the catalog skill
17
+ const removed = [] // present in the catalog skill, absent locally
18
+
19
+ for (const p of all) {
20
+ const l = local.get(p)
21
+ const c = catalog.get(p)
22
+ if (l !== undefined && c !== undefined) (l === c ? identical : modified).push(p)
23
+ else if (l !== undefined) added.push(p)
24
+ else removed.push(p)
25
+ }
26
+
27
+ const total = all.size
28
+ const similarity = total === 0 ? 1 : identical.length / total
29
+ const differing = [...modified, ...added, ...removed].sort()
30
+
31
+ return {
32
+ similarity,
33
+ identical: identical.sort(),
34
+ modified: modified.sort(),
35
+ added: added.sort(),
36
+ removed: removed.sort(),
37
+ differing,
38
+ }
39
+ }
40
+
41
+ module.exports = { diff_manifests }
@@ -0,0 +1,179 @@
1
+ 'use strict'
2
+ // The match funnel + tier classification (spec 260629-03 §4.7).
3
+ //
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`.
17
+ //
18
+ // Closed tier set: certified · near · likely · possible · unmatched.
19
+
20
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
21
+ const { bounded, DEFAULT_CONCURRENCY } = require('../utils/concurrency')
22
+ const { diff_manifests } = require('./diff')
23
+
24
+ // Identical-file fraction above which a NAME-only match is promoted to `near`.
25
+ const DEFAULT_THRESHOLD = 0.5
26
+ // Mirror of the server cap (MATCH_MAX_MANIFESTS) so we chunk manifest requests.
27
+ const MANIFEST_CHUNK = 50
28
+
29
+ const key_of = (c) => `${String(c.workspace).toLowerCase()}/${c.name}@${c.version}`
30
+
31
+ const local_view = (skill) => ({
32
+ name: skill.name,
33
+ dir: skill.dir,
34
+ file_count: skill.file_count,
35
+ skill_md_sha: skill.skill_md_sha,
36
+ tree_sha: skill.tree_sha,
37
+ })
38
+
39
+ const match_view = (c) => ({
40
+ workspace: c.workspace,
41
+ name: c.name,
42
+ version: c.version,
43
+ tree_sha: c.tree_sha,
44
+ is_source_of_truth: !!c.is_source_of_truth,
45
+ })
46
+
47
+ // deps: { match(hashes,names), manifests(repo_versions), semantic_search?(query) }
48
+ // opts: { threshold, concurrency }
49
+ const run_funnel = (skills, deps, opts = {}) =>
50
+ catch_errors('Match funnel failed', async () => {
51
+ const threshold = typeof opts.threshold === 'number' ? opts.threshold : DEFAULT_THRESHOLD
52
+ const concurrency = opts.concurrency || DEFAULT_CONCURRENCY
53
+ const buckets = { certified: [], near: [], likely: [], possible: [], unmatched: [] }
54
+ const warnings = []
55
+
56
+ if (!skills.length) return { buckets, warnings }
57
+
58
+ // ── Stage 1: one batched match for the whole folder ──────────────────
59
+ const hashes = [...new Set(skills.map(s => s.skill_md_sha).filter(Boolean))]
60
+ const names = [...new Set(skills.map(s => s.name).filter(Boolean))]
61
+ const [m_err, match_data] = await deps.match(hashes, names)
62
+ if (m_err) throw e('Stage 1 /repos:match failed', m_err)
63
+
64
+ const by_hash = new Map((match_data.results || []).map(r => [r.skill_md_sha, r.candidates || []]))
65
+ const by_name = new Map((match_data.name_results || []).map(r => [String(r.name).toLowerCase(), r.candidates || []]))
66
+
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()
71
+
72
+ for (const skill of skills) {
73
+ const hash_cands = (skill.skill_md_sha && by_hash.get(skill.skill_md_sha)) || []
74
+ const name_cands = by_name.get(String(skill.name || '').toLowerCase()) || []
75
+
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) })
83
+ continue
84
+ }
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' })
95
+ continue
96
+ }
97
+ none.push(skill)
98
+ }
99
+
100
+ // ── Stage 3: bounded manifest fetch (chunked to the server cap) ──────
101
+ const manifest_by_key = new Map()
102
+ const needs = [...manifest_needs.values()]
103
+ if (needs.length) {
104
+ const chunks = []
105
+ for (let i = 0; i < needs.length; i += MANIFEST_CHUNK) chunks.push(needs.slice(i, i + MANIFEST_CHUNK))
106
+ const results = await bounded(chunks.map(chunk => () => deps.manifests(chunk)), concurrency)
107
+ for (const r of results) {
108
+ if (r && r.error) { warnings.push({ code: 'MANIFEST_FETCH_FAILED', message: String((r.error && r.error.message) || r.error) }); continue }
109
+ const [err, data] = r || [null, null]
110
+ if (err || !data) { warnings.push({ code: 'MANIFEST_FETCH_FAILED', message: 'A manifest batch could not be fetched.' }); continue }
111
+ for (const m of (data.manifests || [])) manifest_by_key.set(key_of(m), m)
112
+ for (const w of (data.warnings || [])) warnings.push(w)
113
+ }
114
+ }
115
+
116
+ // ── finalize the hash/name candidates with their diffs ───────────────
117
+ 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,
125
+ }
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)
131
+ } else {
132
+ buckets.likely.push(entry)
133
+ }
134
+ }
135
+
136
+ // ── Stage 4: semantic fallback for the rest (bounded) ────────────────
137
+ // deps.semantic_search returns the api-client tuple [err, hits] (hits is
138
+ // an array, or { results: [...] }); a failure degrades the skill to
139
+ // `unmatched` with a surfaced warning — never a silent drop.
140
+ if (none.length && typeof deps.semantic_search === 'function') {
141
+ // The thunk ALWAYS resolves (carrying its skill), so a search that
142
+ // rejects can't strand the skill — it still lands in `unmatched` with a
143
+ // surfaced warning, never silently dropped from `data`.
144
+ const results = await bounded(
145
+ none.map(skill => () =>
146
+ deps.semantic_search(skill.name).then(res => ({ skill, res })).catch(error => ({ skill, error }))),
147
+ concurrency
148
+ )
149
+ for (const r of results) {
150
+ const { skill, res, error } = r || {}
151
+ if (!skill) continue // defensive: bounded never yields a bare {error} here
152
+ const tuple_err = Array.isArray(res) ? res[0] : null
153
+ if (error || tuple_err) {
154
+ warnings.push({ code: 'SEMANTIC_SEARCH_FAILED', message: String((error && error.message) || 'A semantic lookup failed.') })
155
+ buckets.unmatched.push({ local: local_view(skill) })
156
+ continue
157
+ }
158
+ const data = Array.isArray(res) ? res[1] : res
159
+ const hits = Array.isArray(data) ? data : (data && Array.isArray(data.results) ? data.results : [])
160
+ if (hits[0]) buckets.possible.push({ local: local_view(skill), match: semantic_match_view(hits[0]) })
161
+ else buckets.unmatched.push({ local: local_view(skill) })
162
+ }
163
+ } else {
164
+ for (const skill of none) buckets.unmatched.push({ local: local_view(skill) })
165
+ }
166
+
167
+ return { buckets, warnings }
168
+ })
169
+
170
+ // A semantic hit shape varies by search response; normalize the fields the
171
+ // match report cares about, leaving missing ones null.
172
+ const semantic_match_view = (hit) => ({
173
+ workspace: hit.workspace_slug || hit.workspace || null,
174
+ name: hit.name || null,
175
+ version: hit.version || null,
176
+ score: typeof hit.score === 'number' ? hit.score : null,
177
+ })
178
+
179
+ module.exports = { run_funnel, DEFAULT_THRESHOLD, MANIFEST_CHUNK }
@@ -0,0 +1,157 @@
1
+ 'use strict'
2
+ // Spec 260629-03 §4.7 — the match funnel + tier classification. Network is
3
+ // mocked (deps injected). The four "done when" guarantees:
4
+ // - identical → certified with ZERO blob fetches (the funnel never fetches
5
+ // blob content at all; classification is hash/tree/manifest only)
6
+ // - LICENSE-only diff → near, listing LICENSE as the sole differing file
7
+ // - SKILL.md differs (name-only, low similarity) → demoted to likely
8
+ // - semantic-only → possible
9
+
10
+ const { test } = require('node:test')
11
+ const assert = require('node:assert/strict')
12
+ const { run_funnel } = require('./funnel')
13
+ const { diff_manifests } = require('./diff')
14
+
15
+ const skill = (over) => ({
16
+ name: 'webapp-testing', dir: '/local/webapp-testing', file_count: 2,
17
+ skill_md_sha: 'h_skillmd', tree_sha: 't_local',
18
+ files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'LICENSE', blob_sha: 'b_lic_local' }],
19
+ ...over,
20
+ })
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
+
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 }
26
+ return {
27
+ calls,
28
+ match: async () => { calls.match++; return [null, { results, name_results }] },
29
+ manifests: async () => { calls.manifests++; return [null, { manifests, warnings: [] }] },
30
+ ...(semantic ? { semantic_search: async (q) => { calls.semantic++; return semantic(q) } } : {}),
31
+ }
32
+ }
33
+
34
+ test('identical content → certified, with zero manifest/blob fetches', async () => {
35
+ const deps = make_deps({ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_local' })] }] })
36
+ const [err, out] = await run_funnel([skill()], deps)
37
+ assert.equal(err, null)
38
+ assert.equal(out.buckets.certified.length, 1)
39
+ assert.equal(out.buckets.certified[0].match.workspace, 'anthropics')
40
+ assert.equal(deps.calls.manifests, 0, 'certified needs no manifest fetch')
41
+ assert.equal(deps.calls.blob, 0, 'the funnel never fetches blob content')
42
+ })
43
+
44
+ test('hash match, only LICENSE differs → near, listing LICENSE as the sole differing file', async () => {
45
+ const deps = make_deps({
46
+ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_remote' })] }],
47
+ manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
48
+ files: [{ path: 'SKILL.md', blob_sha: 'b_skill' }, { path: 'LICENSE', blob_sha: 'b_lic_remote' }] }],
49
+ })
50
+ const [err, out] = await run_funnel([skill()], deps)
51
+ assert.equal(err, null)
52
+ assert.equal(out.buckets.certified.length, 0)
53
+ assert.equal(out.buckets.near.length, 1)
54
+ assert.deepEqual(out.buckets.near[0].differing_files, ['LICENSE'])
55
+ assert.equal(deps.calls.manifests, 1)
56
+ })
57
+
58
+ test('name-only match with low similarity (SKILL.md differs) → likely', async () => {
59
+ // No hash candidate; a name candidate whose manifest shares almost nothing.
60
+ const local = skill({ skill_md_sha: 'h_local_only', tree_sha: 't_local', files: [{ path: 'SKILL.md', blob_sha: 'x1' }, { path: 'a.md', blob_sha: 'x2' }] })
61
+ const deps = make_deps({
62
+ name_results: [{ name: 'webapp-testing', candidates: [cand({ skill_md_sha: 'h_other', tree_sha: 't_remote' })] }],
63
+ manifests: [{ workspace: 'anthropics', name: 'webapp-testing', version: '1.0.0', tree_sha: 't_remote',
64
+ files: [{ path: 'SKILL.md', blob_sha: 'y1' }, { path: 'b.md', blob_sha: 'y2' }, { path: 'c.md', blob_sha: 'y3' }] }],
65
+ })
66
+ const [err, out] = await run_funnel([local], deps, { threshold: 0.5 })
67
+ assert.equal(err, null)
68
+ assert.equal(out.buckets.near.length, 0)
69
+ assert.equal(out.buckets.likely.length, 1)
70
+ assert.equal(out.buckets.likely[0].match.name, 'webapp-testing')
71
+ })
72
+
73
+ 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' }] })
75
+ const deps = make_deps({
76
+ name_results: [{ name: 'webapp-testing', candidates: [cand({ skill_md_sha: 'h_other', tree_sha: 't_remote' })] }],
77
+ 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' }] }],
79
+ })
80
+ const [err, out] = await run_funnel([local], deps, { threshold: 0.5 })
81
+ assert.equal(err, null)
82
+ assert.equal(out.buckets.near.length, 1)
83
+ })
84
+
85
+ test('no hash/name match, semantic hit → possible', async () => {
86
+ const local = skill({ name: 'novel-skill', skill_md_sha: 'h_novel' })
87
+ const deps = make_deps({ semantic: async () => [null, [{ workspace_slug: 'someone', name: 'similar-thing', version: '2.1.0', score: 0.7 }]] })
88
+ const [err, out] = await run_funnel([local], deps)
89
+ assert.equal(err, null)
90
+ assert.equal(out.buckets.possible.length, 1)
91
+ assert.equal(out.buckets.possible[0].match.workspace, 'someone')
92
+ assert.equal(deps.calls.semantic, 1)
93
+ })
94
+
95
+ test('no match at all (no semantic dep) → unmatched', async () => {
96
+ const local = skill({ name: 'novel-skill', skill_md_sha: 'h_novel' })
97
+ const deps = make_deps({}) // no semantic_search
98
+ const [err, out] = await run_funnel([local], deps)
99
+ assert.equal(err, null)
100
+ assert.equal(out.buckets.unmatched.length, 1)
101
+ assert.equal(out.buckets.possible.length, 0)
102
+ })
103
+
104
+ test('Stage 1 is ONE batched call regardless of skill count (no N+1)', async () => {
105
+ const deps = make_deps({ results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_local' })] }] })
106
+ await run_funnel([skill(), skill({ dir: '/b' }), skill({ dir: '/c' })], deps)
107
+ assert.equal(deps.calls.match, 1, 'the whole folder resolves in one /repos:match call')
108
+ })
109
+
110
+ test('a failed manifest batch becomes a warning, not a thrown error', async () => {
111
+ const deps = {
112
+ calls: {},
113
+ match: async () => [null, { results: [{ skill_md_sha: 'h_skillmd', candidates: [cand({ tree_sha: 't_remote' })] }], name_results: [] }],
114
+ manifests: async () => { throw new Error('network boom') },
115
+ }
116
+ const [err, out] = await run_funnel([skill()], deps)
117
+ assert.equal(err, null, 'one bad batch does not abort the run')
118
+ assert.ok(out.warnings.some(w => w.code === 'MANIFEST_FETCH_FAILED'))
119
+ // hash match still lands in near, just without a differing-file list.
120
+ assert.equal(out.buckets.near.length, 1)
121
+ assert.equal(out.buckets.near[0].differing_files, null)
122
+ })
123
+
124
+ test('certified is tree-SHA equality even via a NAME match (skill has no SKILL.md → null hash)', async () => {
125
+ // A skill.json-only local skill (no SKILL.md) → skill_md_sha null, so it can
126
+ // only be found by name — but if its tree is byte-identical it is still certified.
127
+ const local = skill({ name: 'no-skillmd', skill_md_sha: null, tree_sha: 't_local', files: [{ path: 'skill.json', blob_sha: 'sj' }] })
128
+ const deps = make_deps({ name_results: [{ name: 'no-skillmd', candidates: [cand({ name: 'no-skillmd', tree_sha: 't_local' })] }] })
129
+ const [err, out] = await run_funnel([local], deps)
130
+ assert.equal(err, null)
131
+ assert.equal(out.buckets.certified.length, 1, 'byte-identical (tree equal) is certified even when matched by name')
132
+ assert.equal(out.buckets.near.length, 0)
133
+ assert.equal(out.buckets.likely.length, 0)
134
+ })
135
+
136
+ test('a semantic search that REJECTS still lands the skill in unmatched (never silently dropped)', async () => {
137
+ const local = skill({ name: 'novel-skill', skill_md_sha: 'h_novel' })
138
+ const deps = make_deps({ semantic: async () => { throw new Error('search exploded') } })
139
+ const [err, out] = await run_funnel([local], deps)
140
+ assert.equal(err, null, 'a rejecting semantic lookup does not abort the run')
141
+ assert.equal(out.buckets.unmatched.length, 1, 'the skill is reported as unmatched, not dropped from data')
142
+ assert.equal(out.buckets.unmatched[0].local.name, 'novel-skill')
143
+ assert.ok(out.warnings.some(w => w.code === 'SEMANTIC_SEARCH_FAILED'))
144
+ })
145
+
146
+ test('diff_manifests classifies identical / modified / added / removed', () => {
147
+ const d = diff_manifests(
148
+ [{ path: 'SKILL.md', blob_sha: 'a' }, { path: 'keep', blob_sha: 'k' }, { path: 'local-only', blob_sha: 'z' }],
149
+ [{ path: 'SKILL.md', blob_sha: 'b' }, { path: 'keep', blob_sha: 'k' }, { path: 'remote-only', blob_sha: 'r' }],
150
+ )
151
+ assert.deepEqual(d.modified, ['SKILL.md'])
152
+ assert.deepEqual(d.identical, ['keep'])
153
+ assert.deepEqual(d.added, ['local-only'])
154
+ assert.deepEqual(d.removed, ['remote-only'])
155
+ assert.deepEqual(d.differing, ['SKILL.md', 'local-only', 'remote-only'].sort())
156
+ assert.equal(d.similarity, 1 / 4)
157
+ })
@@ -0,0 +1,129 @@
1
+ 'use strict'
2
+ // Local skill scanner + fileset normalization (spec 260629-03 §4.6).
3
+ //
4
+ // Walks a folder, discovers each skill (a dir containing SKILL.md and/or
5
+ // skill.json), and fingerprints it the SAME way the publish path does — so the
6
+ // locally-computed tree_sha lines up byte-for-byte with the catalog's stored
7
+ // tree_sha and a byte-identical skill matches as `certified`.
8
+ //
9
+ // Normalization is the #1 correctness risk (spec §6 #2): a globally-installed
10
+ // skill carries install cruft (.DS_Store, .git, agent lock files) and is often
11
+ // a symlink (multi-agent installs share one copy). If the local fileset differs
12
+ // from what the push path includes, the tree_sha never matches and identical
13
+ // skills look "different". So we reuse `collect_files` / the EXCLUDE rules the
14
+ // push path uses, and we resolve symlinked skill roots to their real content.
15
+
16
+ const fs = require('fs')
17
+ const path = require('path')
18
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
19
+ const { collect_files } = require('../utils/file_collector')
20
+ const { hash_tree } = require('../utils/git_hash')
21
+
22
+ const has_manifest = (dir) =>
23
+ fs.existsSync(path.join(dir, 'SKILL.md')) || fs.existsSync(path.join(dir, 'skill.json'))
24
+
25
+ // Directories we never descend into while hunting for skill roots — the same
26
+ // install/VCS cruft the push fileset excludes, plus any dotdir.
27
+ const SKIP_DIRS = new Set(['node_modules', '.git'])
28
+
29
+ // Discover skill roots under `root`. A skill root is the FIRST directory on a
30
+ // path that has a SKILL.md/skill.json — we do NOT descend past it, so a skill's
31
+ // own references/ subdir is never mistaken for a separate skill. `fs.statSync`
32
+ // follows symlinks, so a symlinked skill dir (the multi-agent install case)
33
+ // resolves to its real target; we dedupe by realpath so a symlink + its target
34
+ // don't both count.
35
+ const find_skill_roots = (root) => {
36
+ const roots = []
37
+ const seen_real = new Set()
38
+
39
+ const add = (dir) => {
40
+ let real
41
+ try { real = fs.realpathSync(dir) } catch { real = dir }
42
+ if (seen_real.has(real)) return
43
+ seen_real.add(real)
44
+ roots.push(dir)
45
+ }
46
+
47
+ const walk = (dir) => {
48
+ if (has_manifest(dir)) { add(dir); return }
49
+ let entries
50
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
51
+ for (const ent of entries) {
52
+ if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue
53
+ const full = path.join(dir, ent.name)
54
+ let st
55
+ try { st = fs.statSync(full) } catch { continue } // follows symlinks; skip broken
56
+ if (st.isDirectory()) walk(full)
57
+ }
58
+ }
59
+
60
+ walk(root)
61
+ return roots
62
+ }
63
+
64
+ // Read the skill's declared name from skill.json (the field the catalog matches
65
+ // on); fall back to the directory basename when absent or unreadable.
66
+ const read_skill_name = (skill_dir) => {
67
+ const fallback = path.basename(skill_dir)
68
+ try {
69
+ const raw = fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf8')
70
+ const json = JSON.parse(raw)
71
+ return (json && typeof json.name === 'string' && json.name.trim()) ? json.name.trim() : fallback
72
+ } catch {
73
+ return fallback
74
+ }
75
+ }
76
+
77
+ // Fingerprint one skill root into the shape the funnel consumes.
78
+ const fingerprint_skill = (skill_dir) =>
79
+ catch_errors(`Failed to fingerprint skill at ${skill_dir}`, async () => {
80
+ const [err, files] = await collect_files(skill_dir)
81
+ if (err) throw e(`Failed to read skill files at ${skill_dir}`, err)
82
+
83
+ // Normalize separators to '/' so the fingerprint is identical to the
84
+ // stored (POSIX-path) tree regardless of the host OS.
85
+ const norm = files.map(f => ({ path: f.path.split(path.sep).join('/'), blob_sha: f.sha }))
86
+
87
+ // Manifest: [{ path, blob_sha }], sorted for stable comparison/diff.
88
+ const manifest = norm
89
+ .map(f => ({ path: f.path, blob_sha: f.blob_sha }))
90
+ .sort((a, b) => a.path.localeCompare(b.path))
91
+
92
+ // skill_md_sha = the blob hash of the ROOT SKILL.md (case-insensitive
93
+ // match, exactly like the server's compute_skill_md_sha). Null when the
94
+ // skill has no SKILL.md (it can then only name/semantic-match).
95
+ const skill_md_entry = manifest.find(f => f.path.toLowerCase() === 'skill.md')
96
+ const skill_md_sha = skill_md_entry ? skill_md_entry.blob_sha : null
97
+
98
+ // tree_sha = the whole-skill fingerprint, computed with the EXACT server
99
+ // algorithm (git_hash.hash_tree mirrors api/app/utils/git_objects).
100
+ const tree_sha = hash_tree(norm.map(f => ({ path: f.path, sha: f.blob_sha })))
101
+
102
+ return {
103
+ dir: skill_dir,
104
+ name: read_skill_name(skill_dir),
105
+ skill_md_sha,
106
+ tree_sha,
107
+ files: manifest,
108
+ file_count: manifest.length,
109
+ }
110
+ })
111
+
112
+ // Scan a folder → fingerprints for every skill found. Returns
113
+ // { skills: [...], errored: [{ dir, error }] } — a single unreadable skill never
114
+ // aborts the scan (CLAUDE.md no-silent-failures: failures are surfaced, not
115
+ // dropped).
116
+ const scan_folder = (folder) =>
117
+ catch_errors(`Failed to scan folder ${folder}`, async () => {
118
+ const roots = find_skill_roots(folder)
119
+ const skills = []
120
+ const errored = []
121
+ for (const dir of roots) {
122
+ const [err, skill] = await fingerprint_skill(dir)
123
+ if (err) errored.push({ dir, error: err })
124
+ else skills.push(skill)
125
+ }
126
+ return { skills, errored }
127
+ })
128
+
129
+ module.exports = { scan_folder, fingerprint_skill, find_skill_roots, has_manifest, read_skill_name }
@@ -0,0 +1,93 @@
1
+ 'use strict'
2
+ // Spec 260629-03 §4.6 — local skill scanner + fileset normalization.
3
+ //
4
+ // The load-bearing assertion (§6 #2): the scanner's locally-computed tree_sha
5
+ // must equal what the PUSH path produces for the same content — otherwise a
6
+ // byte-identical skill shows as "different" and the `certified` tier is broken.
7
+ // We prove that by computing the expected tree_sha/skill_md_sha with the
8
+ // SERVER's canonical git_objects (imported across the monorepo), independent of
9
+ // the CLI's mirror. We also prove cruft (.DS_Store) is excluded and a symlinked
10
+ // skill resolves to its real content (deduped).
11
+
12
+ const { test, before, after } = require('node:test')
13
+ const assert = require('node:assert/strict')
14
+ const fs = require('fs')
15
+ const os = require('os')
16
+ const path = require('path')
17
+
18
+ const { scan_folder, find_skill_roots, fingerprint_skill } = require('./scanner')
19
+ // The canonical server algorithm — the reference the CLI mirror must match.
20
+ const { hash_tree: canon_tree, hash_blob: canon_blob } = require('../../../api/app/utils/git_objects')
21
+
22
+ let root
23
+ const w = (p, content) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content) }
24
+
25
+ before(() => {
26
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'match-scan-'))
27
+
28
+ // alpha — a clean skill: SKILL.md + a nested reference file.
29
+ w(path.join(root, 'skills/alpha/SKILL.md'), '# Alpha\nDoes alpha things.\n')
30
+ w(path.join(root, 'skills/alpha/references/guide.md'), 'detailed guide\n')
31
+ w(path.join(root, 'skills/alpha/skill.json'), JSON.stringify({ name: 'alpha-skill', version: '1.0.0', type: 'skill' }))
32
+
33
+ // beta — carries install cruft that MUST be excluded from the fingerprint.
34
+ w(path.join(root, 'skills/beta/SKILL.md'), '# Beta\n')
35
+ w(path.join(root, 'skills/beta/.DS_Store'), 'macos cruft\n')
36
+
37
+ // gamma-real + a symlink to it — the multi-agent install shape.
38
+ w(path.join(root, 'links/gamma-real/SKILL.md'), '# Gamma\n')
39
+ w(path.join(root, 'links/gamma-real/extra.txt'), 'x\n')
40
+ fs.symlinkSync(path.join(root, 'links/gamma-real'), path.join(root, 'links/gamma'), 'dir')
41
+ })
42
+
43
+ after(() => { try { fs.rmSync(root, { recursive: true, force: true }) } catch {} })
44
+
45
+ // Expected fingerprint computed from a known fileset using the SERVER algorithm.
46
+ // canon_tree returns { sha, buffer } — we compare the hex sha.
47
+ const expected_tree = (entries) => canon_tree(entries.map(en => ({ path: en.path, sha: canon_blob(Buffer.from(en.content)) }))).sha
48
+
49
+ test('discovers each skill root and does not descend past it (references/ is not a separate skill)', () => {
50
+ const roots = find_skill_roots(path.join(root, 'skills'))
51
+ const names = roots.map(r => path.basename(r)).sort()
52
+ assert.deepEqual(names, ['alpha', 'beta'])
53
+ })
54
+
55
+ test('alpha: tree_sha + skill_md_sha equal the values the PUSH path would produce (server algorithm)', async () => {
56
+ const [err, skill] = await fingerprint_skill(path.join(root, 'skills/alpha'))
57
+ assert.equal(err, null)
58
+ assert.equal(skill.name, 'alpha-skill', 'name comes from skill.json')
59
+
60
+ const files = [
61
+ { path: 'SKILL.md', content: '# Alpha\nDoes alpha things.\n' },
62
+ { path: 'references/guide.md', content: 'detailed guide\n' },
63
+ { path: 'skill.json', content: JSON.stringify({ name: 'alpha-skill', version: '1.0.0', type: 'skill' }) },
64
+ ]
65
+ assert.equal(skill.tree_sha, expected_tree(files), 'CLI tree_sha must match the server hash_tree byte-for-byte')
66
+ assert.equal(skill.skill_md_sha, canon_blob(Buffer.from('# Alpha\nDoes alpha things.\n')), 'skill_md_sha = git-blob hash of SKILL.md')
67
+ assert.deepEqual([...skill.files.map(f => f.path)].sort(), ['SKILL.md', 'references/guide.md', 'skill.json'].sort())
68
+ })
69
+
70
+ test('beta: .DS_Store cruft is excluded from the manifest and the tree_sha', async () => {
71
+ const [err, skill] = await fingerprint_skill(path.join(root, 'skills/beta'))
72
+ assert.equal(err, null)
73
+ assert.ok(!skill.files.some(f => f.path.includes('.DS_Store')), '.DS_Store must not be in the manifest')
74
+ // tree_sha matches a tree of ONLY SKILL.md — proving the cruft never entered the hash.
75
+ assert.equal(skill.tree_sha, expected_tree([{ path: 'SKILL.md', content: '# Beta\n' }]))
76
+ })
77
+
78
+ test('a symlinked skill resolves to real content and is counted once (dedupe by realpath)', async () => {
79
+ const [err, result] = await scan_folder(path.join(root, 'links'))
80
+ assert.equal(err, null)
81
+ assert.equal(result.skills.length, 1, 'symlink + its target must not both count')
82
+ assert.equal(result.skills[0].tree_sha, expected_tree([
83
+ { path: 'SKILL.md', content: '# Gamma\n' },
84
+ { path: 'extra.txt', content: 'x\n' },
85
+ ]), 'fingerprint reflects the real (symlink-followed) content')
86
+ })
87
+
88
+ test('scan_folder surfaces all skills with no false errors', async () => {
89
+ const [err, result] = await scan_folder(path.join(root, 'skills'))
90
+ assert.equal(err, null)
91
+ assert.equal(result.skills.length, 2)
92
+ assert.equal(result.errored.length, 0)
93
+ })
@@ -0,0 +1,28 @@
1
+ 'use strict'
2
+ // Bounded-concurrency wrapper over core-async's `throttle` — the ONE place the
3
+ // match pipeline (and any future fan-out) imports concurrency from (spec
4
+ // 260629-03 §4.5). `throttle` keeps at most `limit` task thunks in flight and
5
+ // pulls the next the instant a slot frees (no fixed-batch head-of-line
6
+ // blocking). Crucially, a FAILED task resolves to `{ error }` instead of
7
+ // throwing — so one bad skill never aborts the whole scan. Callers MUST
8
+ // partition the `{ error }` results and surface them (never drop them silently
9
+ // — CLAUDE.md no-silent-failures).
10
+ //
11
+ // Bounded concurrency is also rate-limit safety: an unbounded fan-out of ~70
12
+ // manifest/blob fetches can trip the API RateLimited alarm (see memory
13
+ // project_rate_limit_observability_and_nudge). Keep the default modest.
14
+
15
+ const { throttle } = require('core-async')
16
+
17
+ const DEFAULT_CONCURRENCY = 6
18
+
19
+ // Deliberately NOT catch_errors-wrapped: it returns throttle's results array
20
+ // verbatim (order-preserving; a failed task is `{ error }`, not a throw) so
21
+ // callers keep the per-task error partition. Wrapping it would collapse that to
22
+ // a single [errors, result] and lose which task failed.
23
+ const bounded = (tasks, limit = DEFAULT_CONCURRENCY) => {
24
+ const n = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CONCURRENCY
25
+ return throttle(Array.isArray(tasks) ? tasks : [], n)
26
+ }
27
+
28
+ module.exports = { bounded, DEFAULT_CONCURRENCY }