happyskills 1.9.0 → 1.10.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 +11 -0
- package/package.json +1 -1
- package/src/commands/resolve.js +325 -0
- package/src/commands/resolve.test.js +169 -0
- package/src/commands/schema.js +95 -8
- package/src/constants/next_step_by_error_code.js +23 -4
- package/src/constants.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.10.0] - 2026-06-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add the `resolve` command — `happyskills resolve "<intent>"` maps a natural-language intent to the skill that owns it, whether that skill is installed, and how to install it if not. It matches the intent against installed skills' declared capabilities first (deterministic); if none match, it falls back to registry search gated on the server's `match_quality` (returning a `probabilistic_match`-flagged suggestion for a strong/good hit, or a graceful empty result for gibberish). Read-only; no auth required for public skills.
|
|
15
|
+
- Extend `happyskills schema --json` with a skill-declared capability registry: a top-level `data.skills[]` (each installed skill's `capabilities` declarations — `name`, `slug`, `version`, `bundled`, and `capabilities[]`) plus a per-command `owner_skill` field. Both are aggregated from installed skills' `skill.json` `capabilities`, never a hardcoded table, so an agent can map a command or intent to its owning skill deterministically.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Broaden `next_step.route_to_skill` coverage so a mis-routed recovery names the skill that should handle it: `VALIDATION_FAILED`, `DEPENDENCY_VALIDATION_FAILED`, `MISSING_CHANGELOG_ENTRY`, and `CHANGELOG_SOURCE_UNREADABLE` now route to `happyskills-publish`, and a new `CONFLICT` default routes to `happyskills-sync` (joining the existing `DRIFT_DETECTED` / `DIVERGED`). No new `error.code` or `next_step.action` values.
|
|
20
|
+
|
|
10
21
|
## [1.9.0] - 2026-06-06
|
|
11
22
|
|
|
12
23
|
### Fixed
|
package/package.json
CHANGED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// happyskills resolve "<intent>" — spec 260606-01 Phase 4b.
|
|
3
|
+
//
|
|
4
|
+
// Read-only intent resolver. Given a natural-language intent, return a grounded
|
|
5
|
+
// envelope naming the capability, its owning skill, whether that skill is
|
|
6
|
+
// installed, and (when not) how to install it. CLI-only orchestration over the
|
|
7
|
+
// §4.1 skill-declared ownership map (deterministic) + the existing registry
|
|
8
|
+
// search endpoint (probabilistic fallback). No server-side endpoint, no in-CLI
|
|
9
|
+
// LLM call — the matching is string/registry-based; the *agent* supplies the
|
|
10
|
+
// intelligence by calling `resolve` and reading the result.
|
|
11
|
+
//
|
|
12
|
+
// Resolution order (deterministic-first):
|
|
13
|
+
// 2a. Match the intent against INSTALLED skills' declared capability.intents.
|
|
14
|
+
// A confident local match returns installed:true — fully deterministic.
|
|
15
|
+
// 2b. Otherwise query the registry for a skill/capability covering the intent
|
|
16
|
+
// and return the top candidate with installed:false + an install command.
|
|
17
|
+
// HONEST CAVEAT: step 2b leans on semantic registry search, which is
|
|
18
|
+
// PROBABILISTIC. `resolve` grounds it in the real registry rather than the
|
|
19
|
+
// LLM's memory — it does not make it deterministic. Do not over-trust a
|
|
20
|
+
// not-installed match.
|
|
21
|
+
|
|
22
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
23
|
+
const { build_ownership } = require('./schema')
|
|
24
|
+
const repos_api = require('../api/repos')
|
|
25
|
+
const { print_help, print_info, print_hint } = require('../ui/output')
|
|
26
|
+
const { bold, dim, cyan, gray } = require('../ui/colors')
|
|
27
|
+
const { emit_envelope } = require('../ui/envelope')
|
|
28
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
29
|
+
const { EXIT_CODES } = require('../constants')
|
|
30
|
+
|
|
31
|
+
const HELP_TEXT = `Usage: happyskills resolve "<intent>" [options]
|
|
32
|
+
|
|
33
|
+
Resolve a natural-language intent to the HappySkills skill that owns it, whether
|
|
34
|
+
that skill is installed, and how to install it if not.
|
|
35
|
+
|
|
36
|
+
Resolution is deterministic-first: a confident match against an INSTALLED skill's
|
|
37
|
+
declared capabilities returns installed:true and is fully deterministic. If no
|
|
38
|
+
installed skill matches, resolve falls back to the registry — that step is
|
|
39
|
+
semantic search and is PROBABILISTIC; treat a not-installed match as a grounded
|
|
40
|
+
suggestion, not a guarantee.
|
|
41
|
+
|
|
42
|
+
Arguments:
|
|
43
|
+
intent A natural-language description of what you want to do
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--json Output as JSON envelope (default for non-TTY callers)
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
happyskills resolve "how many people installed my skills"
|
|
50
|
+
happyskills resolve "invite alice to my workspace" --json`
|
|
51
|
+
|
|
52
|
+
// ── deterministic local matcher (pure; exported for tests) ───────────────────
|
|
53
|
+
|
|
54
|
+
const STOPWORDS = new Set([
|
|
55
|
+
'a', 'an', 'the', 'my', 'me', 'i', 'to', 'of', 'for', 'is', 'are', 'be',
|
|
56
|
+
'do', 'does', 'did', 'how', 'what', 'which', 'can', 'could', 'would', 'you',
|
|
57
|
+
'please', 'with', 'on', 'in', 'it', 'this', 'that', 'and', 'or', 'your',
|
|
58
|
+
'we', 'us', 'am', 'have', 'has', 'had', 'about', 'into', 'from', 'so',
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
const tokenize = (s) => String(s || '')
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
64
|
+
.split(' ')
|
|
65
|
+
.filter(t => t.length >= 2 && !STOPWORDS.has(t))
|
|
66
|
+
|
|
67
|
+
// Coverage of an intent phrase's significant tokens by the query token set,
|
|
68
|
+
// plus the raw count of shared tokens (used by the confidence gate).
|
|
69
|
+
const score_intent = (query_set, intent_phrase) => {
|
|
70
|
+
const it = [...new Set(tokenize(intent_phrase))]
|
|
71
|
+
if (it.length === 0) return { score: 0, shared: 0 }
|
|
72
|
+
let shared = 0
|
|
73
|
+
for (const t of it) if (query_set.has(t)) shared++
|
|
74
|
+
return { score: shared / it.length, shared }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Best (skill, capability, intent) match across all declared capabilities of the
|
|
78
|
+
// installed skills. Returns null when nothing overlaps. A query token that
|
|
79
|
+
// equals one of a capability's owned command names is treated as a strong
|
|
80
|
+
// signal (score floored to 1).
|
|
81
|
+
const match_local = (intent, skills) => {
|
|
82
|
+
const query_set = new Set(tokenize(intent))
|
|
83
|
+
if (query_set.size === 0) return null
|
|
84
|
+
let best = null
|
|
85
|
+
for (const skill of skills || []) {
|
|
86
|
+
for (const cap of skill.capabilities || []) {
|
|
87
|
+
for (const phrase of cap.intents || []) {
|
|
88
|
+
const { score, shared } = score_intent(query_set, phrase)
|
|
89
|
+
if (shared > 0 && (!best || score > best.score || (score === best.score && shared > best.shared))) {
|
|
90
|
+
best = { skill, capability: cap, intent_phrase: phrase, score, shared }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Command-name hit — ONLY when the query IS essentially the bare
|
|
94
|
+
// command (a single content token, e.g. "publish" or "stats").
|
|
95
|
+
// Command names are often common English words (access, check, list,
|
|
96
|
+
// star, status, diff, pull, update...), so matching one inside a
|
|
97
|
+
// longer, unrelated query produces confident false positives — e.g.
|
|
98
|
+
// "access the database" → collab, "check the weather" → core. Gating
|
|
99
|
+
// on a single-token query keeps bare-command resolution while leaving
|
|
100
|
+
// multi-word phrasing to the intent matcher.
|
|
101
|
+
if (query_set.size === 1) {
|
|
102
|
+
for (const cmd of cap.commands || []) {
|
|
103
|
+
if (query_set.has(cmd.toLowerCase())) {
|
|
104
|
+
const cand = { skill, capability: cap, intent_phrase: cap.intents?.[0] || cmd, score: 1, shared: 1 }
|
|
105
|
+
if (!best || cand.score > best.score) best = cand
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return best
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Confidence gate. Deliberately coarse — this is a deterministic floor, not a
|
|
115
|
+
// ranker (spec §6.5: do not tune ranking). Two shared significant tokens at ≥50%
|
|
116
|
+
// coverage, OR a near-exact single-strong match (≥80% coverage).
|
|
117
|
+
const is_confident = (m) => !!m && ((m.shared >= 2 && m.score >= 0.5) || m.score >= 0.8)
|
|
118
|
+
|
|
119
|
+
// Registry-fallback relevance floor (pure; exported for tests).
|
|
120
|
+
// Semantic search almost always returns SOMETHING, so taking items[0] blindly
|
|
121
|
+
// turns gibberish into a confident install instruction for an unrelated skill
|
|
122
|
+
// (spec §3 Phase-4b requires gibberish → graceful empty). relevance_score is
|
|
123
|
+
// uniformly ~0.03 on the pre-launch registry (untuned, spec §6.5) so it is NOT
|
|
124
|
+
// a usable floor — the server's own match_quality bucketing is. Accept only a
|
|
125
|
+
// strong/good top match; partial/weak/absent → null → graceful path.
|
|
126
|
+
const ACCEPTABLE_REGISTRY_QUALITY = new Set(['strong', 'good'])
|
|
127
|
+
|
|
128
|
+
const registry_candidate = (items) => {
|
|
129
|
+
const top = items && items.length ? items[0] : null
|
|
130
|
+
if (!top) return null
|
|
131
|
+
return ACCEPTABLE_REGISTRY_QUALITY.has(top.match_quality) ? top : null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── next_step builders (pure; exported for tests) ────────────────────────────
|
|
135
|
+
|
|
136
|
+
// Installed: the owning skill is present — name it via route_to_skill so the
|
|
137
|
+
// agent lets it fire. Reuses the existing continuation action present_to_user.
|
|
138
|
+
const installed_next_step = (owner_skill, trigger) => ({
|
|
139
|
+
kind: 'continuation',
|
|
140
|
+
action: 'present_to_user',
|
|
141
|
+
route_to_skill: owner_skill,
|
|
142
|
+
instructions: `The capability is owned by \`${owner_skill}\`, which is installed. Present the match to the principal — the skill will handle the request when the intent is re-stated.`,
|
|
143
|
+
context: { ...(trigger ? { trigger } : {}) },
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Not installed: route to install. Reuses the existing routing action
|
|
147
|
+
// install_first; route_to_skill names the owning skill.
|
|
148
|
+
const not_installed_next_step = (owner_skill, slug) => ({
|
|
149
|
+
kind: 'routing',
|
|
150
|
+
action: 'install_first',
|
|
151
|
+
route_to_skill: owner_skill,
|
|
152
|
+
instructions: `The owning skill is not installed. Install it, then re-state the intent so it can handle the request.`,
|
|
153
|
+
context: { commands: [`npx happyskills install ${slug} --json`] },
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// Nothing confidently matched — a valid, graceful outcome (never a crash, never
|
|
157
|
+
// an invented error code).
|
|
158
|
+
const empty_next_step = () => ({
|
|
159
|
+
kind: 'continuation',
|
|
160
|
+
action: 'present_to_user',
|
|
161
|
+
instructions: `No installed capability or registry skill confidently matches this intent. Present this honestly to the principal — consider rephrasing, or search the registry directly with \`happyskills search\`.`,
|
|
162
|
+
context: {},
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// ── resolution ───────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const resolve_intent = (intent) => catch_errors('Resolve failed', async () => {
|
|
168
|
+
const [, ownership] = await build_ownership()
|
|
169
|
+
const skills = ownership?.skills || []
|
|
170
|
+
const installed_slugs = new Set(skills.map(s => s.slug))
|
|
171
|
+
|
|
172
|
+
// 2a — deterministic local match against installed capabilities.
|
|
173
|
+
const local = match_local(intent, skills)
|
|
174
|
+
if (is_confident(local)) {
|
|
175
|
+
return {
|
|
176
|
+
data: {
|
|
177
|
+
intent,
|
|
178
|
+
capability: local.capability.id,
|
|
179
|
+
summary: local.capability.summary || null,
|
|
180
|
+
owner_skill: local.skill.name,
|
|
181
|
+
slug: local.skill.slug,
|
|
182
|
+
installed: true,
|
|
183
|
+
resolution: 'installed_capability',
|
|
184
|
+
},
|
|
185
|
+
next_step: installed_next_step(local.skill.name, local.intent_phrase),
|
|
186
|
+
warnings: [],
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 2b — registry search fallback (PROBABILISTIC). Public read; no auth required.
|
|
191
|
+
const [search_errors, response] = await repos_api.dispatch_search(intent, { limit: 5 })
|
|
192
|
+
if (search_errors) throw e('Registry search failed', search_errors)
|
|
193
|
+
const items = Array.isArray(response) ? response : (response?.data || response?.results || [])
|
|
194
|
+
const top = registry_candidate(items)
|
|
195
|
+
|
|
196
|
+
if (top) {
|
|
197
|
+
const owner = top.workspace_slug || top.owner || null
|
|
198
|
+
const slug = owner ? `${owner}/${top.name}` : top.name
|
|
199
|
+
const already_installed = installed_slugs.has(slug)
|
|
200
|
+
return {
|
|
201
|
+
data: {
|
|
202
|
+
intent,
|
|
203
|
+
capability: null,
|
|
204
|
+
summary: top.description || null,
|
|
205
|
+
owner_skill: top.name,
|
|
206
|
+
slug,
|
|
207
|
+
installed: already_installed,
|
|
208
|
+
resolution: 'registry_search',
|
|
209
|
+
},
|
|
210
|
+
next_step: already_installed
|
|
211
|
+
? installed_next_step(top.name, null)
|
|
212
|
+
: not_installed_next_step(top.name, slug),
|
|
213
|
+
warnings: [{
|
|
214
|
+
code: 'probabilistic_match',
|
|
215
|
+
message: 'This match comes from semantic registry search and is probabilistic — verify it fits the intent before trusting a not-installed result.',
|
|
216
|
+
}],
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// No match anywhere — graceful empty result.
|
|
221
|
+
return {
|
|
222
|
+
data: {
|
|
223
|
+
intent,
|
|
224
|
+
capability: null,
|
|
225
|
+
summary: null,
|
|
226
|
+
owner_skill: null,
|
|
227
|
+
slug: null,
|
|
228
|
+
installed: false,
|
|
229
|
+
resolution: 'none',
|
|
230
|
+
},
|
|
231
|
+
next_step: empty_next_step(),
|
|
232
|
+
warnings: [],
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const print_human = (result) => {
|
|
237
|
+
const d = result.data
|
|
238
|
+
if (d.resolution === 'none') {
|
|
239
|
+
print_info(`No skill confidently owns: "${d.intent}"`)
|
|
240
|
+
print_hint('Try rephrasing, or run `happyskills search "<terms>"` to browse the registry.')
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const where = d.installed ? cyan('installed') : dim('not installed')
|
|
244
|
+
console.log(`\n${bold(d.owner_skill)} ${where}`)
|
|
245
|
+
if (d.summary) console.log(` ${d.summary}`)
|
|
246
|
+
if (d.capability) console.log(` ${dim('capability: ' + d.capability)}`)
|
|
247
|
+
if (!d.installed && d.slug) {
|
|
248
|
+
console.log(`\n ${gray('Install with:')} happyskills install ${d.slug}`)
|
|
249
|
+
}
|
|
250
|
+
if (d.resolution === 'registry_search') {
|
|
251
|
+
console.log(`\n ${dim('(registry match — semantic search is probabilistic; verify it fits.)')}`)
|
|
252
|
+
}
|
|
253
|
+
console.log('')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const run = (args) => catch_errors('Resolve failed', async () => {
|
|
257
|
+
if (args.flags._show_help) {
|
|
258
|
+
print_help(HELP_TEXT)
|
|
259
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const intent = args._.join(' ').trim()
|
|
263
|
+
if (!intent) {
|
|
264
|
+
throw new UsageError('An intent is required. Example: happyskills resolve "how many people installed my skills".')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const [errors, result] = await resolve_intent(intent)
|
|
268
|
+
if (errors) throw e('Resolve failed', errors)
|
|
269
|
+
|
|
270
|
+
if (args.flags.json) {
|
|
271
|
+
emit_envelope({ data: result.data, next_step: result.next_step, warnings: result.warnings })
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
print_human(result)
|
|
275
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
276
|
+
|
|
277
|
+
const schema = {
|
|
278
|
+
name: 'resolve',
|
|
279
|
+
audience: 'consumer',
|
|
280
|
+
purpose: 'Resolve a natural-language intent to the owning skill, whether it is installed, and how to install it. Deterministic against installed capabilities; probabilistic registry fallback otherwise.',
|
|
281
|
+
mutation: false,
|
|
282
|
+
interactive_in_text_mode: false,
|
|
283
|
+
input: {
|
|
284
|
+
positional: [
|
|
285
|
+
{ name: 'intent', required: true, type: 'string', description: 'Natural-language description of what you want to do' },
|
|
286
|
+
],
|
|
287
|
+
flags: [
|
|
288
|
+
{ name: 'json', type: 'boolean', default: false, description: 'Output as JSON envelope' },
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
output: {
|
|
292
|
+
data_shape: {
|
|
293
|
+
intent: 'string',
|
|
294
|
+
capability: 'string|null',
|
|
295
|
+
summary: 'string|null',
|
|
296
|
+
owner_skill: 'string|null',
|
|
297
|
+
slug: 'string|null',
|
|
298
|
+
installed: 'boolean',
|
|
299
|
+
resolution: 'installed_capability | registry_search | none',
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
errors: [
|
|
303
|
+
{ code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
|
|
304
|
+
{ code: 'NETWORK_ERROR', next_step: { kind: 'recovery', action: 'retry' } },
|
|
305
|
+
],
|
|
306
|
+
examples: [
|
|
307
|
+
'happyskills resolve "how many people installed my skills" --json',
|
|
308
|
+
'happyskills resolve "invite alice to my workspace" --json',
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
run,
|
|
314
|
+
schema,
|
|
315
|
+
// pure helpers (exported for tests)
|
|
316
|
+
tokenize,
|
|
317
|
+
score_intent,
|
|
318
|
+
match_local,
|
|
319
|
+
is_confident,
|
|
320
|
+
registry_candidate,
|
|
321
|
+
installed_next_step,
|
|
322
|
+
not_installed_next_step,
|
|
323
|
+
empty_next_step,
|
|
324
|
+
resolve_intent,
|
|
325
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Tests for `happyskills resolve` — spec 260606-01 Phase 4b.
|
|
3
|
+
// Pure-function coverage (deterministic matcher + next_step builders) plus an
|
|
4
|
+
// envelope-conformance check: every next_step resolve can emit must be a valid
|
|
5
|
+
// six-key envelope with a closed-enum action. No network — 2b is not exercised.
|
|
6
|
+
|
|
7
|
+
const { describe, it } = require('node:test')
|
|
8
|
+
const assert = require('node:assert/strict')
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
tokenize,
|
|
12
|
+
score_intent,
|
|
13
|
+
match_local,
|
|
14
|
+
is_confident,
|
|
15
|
+
registry_candidate,
|
|
16
|
+
installed_next_step,
|
|
17
|
+
not_installed_next_step,
|
|
18
|
+
empty_next_step,
|
|
19
|
+
} = require('./resolve')
|
|
20
|
+
const { build_envelope } = require('../ui/envelope')
|
|
21
|
+
const { validate_envelope } = require('../schema/envelope_validator')
|
|
22
|
+
const { NEXT_STEP_ACTION_SET } = require('../constants/next_step_actions')
|
|
23
|
+
|
|
24
|
+
// Fixture mirroring the real skill-declared capabilities (a subset).
|
|
25
|
+
const SKILLS = [
|
|
26
|
+
{
|
|
27
|
+
name: 'happyskills-stats', slug: 'happyskillsai/happyskills-stats', bundled: false,
|
|
28
|
+
capabilities: [{
|
|
29
|
+
id: 'usage-stats',
|
|
30
|
+
summary: 'report your own HappySkills usage',
|
|
31
|
+
intents: ['how many people installed my skills', 'show my usage', 'my install or search history'],
|
|
32
|
+
commands: ['stats'],
|
|
33
|
+
}],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'happyskills-collab', slug: 'happyskillsai/happyskills-collab', bundled: false,
|
|
37
|
+
capabilities: [{
|
|
38
|
+
id: 'workspace-collaboration',
|
|
39
|
+
summary: 'invite members and grant access',
|
|
40
|
+
intents: ['invite someone to my workspace', 'grant or revoke skill access'],
|
|
41
|
+
commands: ['people', 'groups', 'access'],
|
|
42
|
+
}],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'happyskills-publish', slug: 'happyskillsai/happyskills-publish', bundled: true,
|
|
46
|
+
capabilities: [{
|
|
47
|
+
id: 'publish-skill',
|
|
48
|
+
summary: 'publish and release skills',
|
|
49
|
+
intents: ['publish my skill', 'release my skill'],
|
|
50
|
+
commands: ['publish', 'release', 'bump'],
|
|
51
|
+
}],
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
describe('resolve — tokenize', () => {
|
|
56
|
+
it('lowercases, strips punctuation, drops stopwords and short tokens', () => {
|
|
57
|
+
assert.deepEqual(tokenize('How many people installed MY skills?'), ['many', 'people', 'installed', 'skills'])
|
|
58
|
+
})
|
|
59
|
+
it('returns empty for pure stopwords / noise', () => {
|
|
60
|
+
assert.deepEqual(tokenize('how do I'), [])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('resolve — score_intent', () => {
|
|
65
|
+
it('exact phrase scores 1.0 with full shared count', () => {
|
|
66
|
+
const q = new Set(tokenize('how many people installed my skills'))
|
|
67
|
+
const { score, shared } = score_intent(q, 'how many people installed my skills')
|
|
68
|
+
assert.equal(score, 1)
|
|
69
|
+
assert.equal(shared, 4)
|
|
70
|
+
})
|
|
71
|
+
it('partial overlap scores between 0 and 1', () => {
|
|
72
|
+
const q = new Set(tokenize('invite alice to my workspace'))
|
|
73
|
+
const { score, shared } = score_intent(q, 'invite someone to my workspace')
|
|
74
|
+
assert.ok(score > 0 && score < 1)
|
|
75
|
+
assert.equal(shared, 2) // invite, workspace
|
|
76
|
+
})
|
|
77
|
+
it('no overlap scores 0', () => {
|
|
78
|
+
const q = new Set(tokenize('asdfqwer nonsense'))
|
|
79
|
+
assert.deepEqual(score_intent(q, 'publish my skill'), { score: 0, shared: 0 })
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('resolve — match_local + confidence gate', () => {
|
|
84
|
+
it('routes a stats intent to happyskills-stats (confident)', () => {
|
|
85
|
+
const m = match_local('how many people installed my skills', SKILLS)
|
|
86
|
+
assert.equal(m.skill.name, 'happyskills-stats')
|
|
87
|
+
assert.equal(m.capability.id, 'usage-stats')
|
|
88
|
+
assert.equal(is_confident(m), true)
|
|
89
|
+
})
|
|
90
|
+
it('routes a collab intent to happyskills-collab (confident)', () => {
|
|
91
|
+
const m = match_local('invite alice to my workspace', SKILLS)
|
|
92
|
+
assert.equal(m.skill.name, 'happyskills-collab')
|
|
93
|
+
assert.equal(is_confident(m), true)
|
|
94
|
+
})
|
|
95
|
+
it('a bare command word ("publish") routes via command-name match', () => {
|
|
96
|
+
const m = match_local('publish', SKILLS)
|
|
97
|
+
assert.equal(m.skill.name, 'happyskills-publish')
|
|
98
|
+
assert.equal(is_confident(m), true)
|
|
99
|
+
})
|
|
100
|
+
it('gibberish yields no confident match', () => {
|
|
101
|
+
const m = match_local('asdfqwer zzz', SKILLS)
|
|
102
|
+
assert.equal(is_confident(m), false) // null or low-overlap
|
|
103
|
+
})
|
|
104
|
+
it('does not cross-wire collab and stats intents', () => {
|
|
105
|
+
assert.equal(match_local('grant or revoke skill access', SKILLS).skill.name, 'happyskills-collab')
|
|
106
|
+
assert.equal(match_local('show my usage', SKILLS).skill.name, 'happyskills-stats')
|
|
107
|
+
})
|
|
108
|
+
// Durable guard: command names are common English words (access, publish,
|
|
109
|
+
// stats...). A longer, unrelated query that merely CONTAINS one must NOT be
|
|
110
|
+
// a confident match — only a bare-command query may use the command shortcut.
|
|
111
|
+
it('does not confidently match an out-of-domain query that merely contains a command word', () => {
|
|
112
|
+
assert.equal(is_confident(match_local('access the production database', SKILLS)), false)
|
|
113
|
+
assert.equal(is_confident(match_local('publish a blog post about marketing', SKILLS)), false)
|
|
114
|
+
assert.equal(is_confident(match_local('show me the stats of the football game', SKILLS)), false)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('resolve — registry relevance floor (2b)', () => {
|
|
119
|
+
// Durable guard: semantic search almost always returns SOMETHING, so the
|
|
120
|
+
// registry fallback must gate on the server's match_quality, never blindly
|
|
121
|
+
// take items[0] (which turned gibberish into a confident install_first).
|
|
122
|
+
it('rejects a partial/weak top match (→ null → graceful path)', () => {
|
|
123
|
+
assert.equal(registry_candidate([{ name: 'abc-xyz-classifier', match_quality: 'partial' }]), null)
|
|
124
|
+
assert.equal(registry_candidate([{ name: 'x', match_quality: 'weak' }]), null)
|
|
125
|
+
})
|
|
126
|
+
it('rejects a top match with no match_quality (defensive)', () => {
|
|
127
|
+
assert.equal(registry_candidate([{ name: 'x', relevance_score: 0.9 }]), null)
|
|
128
|
+
})
|
|
129
|
+
it('accepts a strong or good top match', () => {
|
|
130
|
+
assert.equal(registry_candidate([{ name: 'happyskills-collab', match_quality: 'strong' }]).name, 'happyskills-collab')
|
|
131
|
+
assert.equal(registry_candidate([{ name: 'database', match_quality: 'good' }]).name, 'database')
|
|
132
|
+
})
|
|
133
|
+
it('returns null for an empty result set', () => {
|
|
134
|
+
assert.equal(registry_candidate([]), null)
|
|
135
|
+
assert.equal(registry_candidate(null), null)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('resolve — next_step builders emit valid envelopes', () => {
|
|
140
|
+
const assert_valid = (next_step) => {
|
|
141
|
+
const env = build_envelope({ data: { ok: 1 }, next_step })
|
|
142
|
+
const { ok, errors } = validate_envelope(env)
|
|
143
|
+
assert.ok(ok, `envelope invalid: ${JSON.stringify(errors)}`)
|
|
144
|
+
assert.ok(NEXT_STEP_ACTION_SET.has(env.next_step.action), `action ${env.next_step.action} not in closed enum`)
|
|
145
|
+
return env
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it('installed → continuation/present_to_user with route_to_skill', () => {
|
|
149
|
+
const env = assert_valid(installed_next_step('happyskills-stats', 'show my usage'))
|
|
150
|
+
assert.equal(env.next_step.kind, 'continuation')
|
|
151
|
+
assert.equal(env.next_step.action, 'present_to_user')
|
|
152
|
+
assert.equal(env.next_step.route_to_skill, 'happyskills-stats')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('not installed → routing/install_first with the install command', () => {
|
|
156
|
+
const env = assert_valid(not_installed_next_step('happyskills-collab', 'happyskillsai/happyskills-collab'))
|
|
157
|
+
assert.equal(env.next_step.kind, 'routing')
|
|
158
|
+
assert.equal(env.next_step.action, 'install_first')
|
|
159
|
+
assert.equal(env.next_step.route_to_skill, 'happyskills-collab')
|
|
160
|
+
assert.equal(env.next_step.context.commands[0], 'npx happyskills install happyskillsai/happyskills-collab --json')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('empty → continuation/present_to_user, ok:true (graceful, no invented code)', () => {
|
|
164
|
+
const env = assert_valid(empty_next_step())
|
|
165
|
+
assert.equal(env.ok, true)
|
|
166
|
+
assert.equal(env.next_step.action, 'present_to_user')
|
|
167
|
+
assert.deepEqual(env.error, {})
|
|
168
|
+
})
|
|
169
|
+
})
|
package/src/commands/schema.js
CHANGED
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
// yet (defensive, additive). The schema lives next to the implementation
|
|
11
11
|
// so there's no drift.
|
|
12
12
|
|
|
13
|
+
const path = require('path')
|
|
13
14
|
const { error: { catch_errors } } = require('puffy-core')
|
|
14
|
-
const { COMMANDS, COMMAND_ALIASES, CLI_VERSION } = require('../constants')
|
|
15
|
+
const { COMMANDS, COMMAND_ALIASES, CLI_VERSION, SKILL_JSON } = require('../constants')
|
|
16
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
17
|
+
const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
|
|
18
|
+
const { read_json } = require('../utils/fs')
|
|
15
19
|
const { ERROR_CODE_LIST } = require('../constants/error_codes')
|
|
16
20
|
const {
|
|
17
21
|
NEXT_STEP_ACTION_LIST,
|
|
@@ -103,10 +107,90 @@ const load_command_schema = (name) => {
|
|
|
103
107
|
return default_schema(name)
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
|
|
110
|
+
// ── Skill-declared capability ownership (spec 260606-01 Phase 4a) ────────────
|
|
111
|
+
// Each installed skill MAY declare a `capabilities` array in its skill.json:
|
|
112
|
+
// [{ id, summary, intents: [...], commands: [...] }]
|
|
113
|
+
// We aggregate these from the locally-installed skills (project + global lock)
|
|
114
|
+
// and expose two derived shapes:
|
|
115
|
+
// - data.skills[] — the capability registry, one entry per
|
|
116
|
+
// declaring skill (name, slug, bundled, caps)
|
|
117
|
+
// - data.commands[].owner_skill — command → owning skill, DERIVED from the
|
|
118
|
+
// `commands` field of each declared capability
|
|
119
|
+
// Ownership is skill-DECLARED and CLI-AGGREGATED. There is deliberately NO
|
|
120
|
+
// hardcoded skill-family or command→skill table here (spec §5 — the one
|
|
121
|
+
// architectural rule). `bundled` is derived generically: a skill is bundled
|
|
122
|
+
// when it appears as a dependency of any other installed skill (i.e. it arrives
|
|
123
|
+
// pulled-in rather than installed on its own) — family-agnostic, lock-derived.
|
|
124
|
+
// This generalizes the spec's "membership in core's dependencies" to "any
|
|
125
|
+
// installed skill's dependencies" precisely so the CLI never names a family.
|
|
126
|
+
|
|
127
|
+
const read_installed_manifests = (is_global) => catch_errors('Failed to read installed skills', async () => {
|
|
128
|
+
const project_root = find_project_root()
|
|
129
|
+
const base_dir = skills_dir(is_global, project_root)
|
|
130
|
+
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
131
|
+
const locked = get_all_locked_skills(lock_data)
|
|
132
|
+
const entries = await Promise.all(Object.keys(locked).map(async (slug) => {
|
|
133
|
+
const short = slug.split('/')[1] || slug
|
|
134
|
+
const dir = skill_install_dir(base_dir, short)
|
|
135
|
+
const [, manifest] = await read_json(path.join(dir, SKILL_JSON))
|
|
136
|
+
if (!manifest) return null
|
|
137
|
+
return { slug, short, manifest }
|
|
138
|
+
}))
|
|
139
|
+
return entries.filter(Boolean)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const normalise_capabilities = (caps) =>
|
|
143
|
+
(Array.isArray(caps) ? caps : []).map(c => ({
|
|
144
|
+
id: c && c.id ? c.id : null,
|
|
145
|
+
summary: c && c.summary ? c.summary : '',
|
|
146
|
+
intents: c && Array.isArray(c.intents) ? c.intents : [],
|
|
147
|
+
commands: c && Array.isArray(c.commands) ? c.commands : [],
|
|
148
|
+
}))
|
|
149
|
+
|
|
150
|
+
const build_ownership = () => catch_errors('Failed to build skill ownership', async () => {
|
|
151
|
+
// Merge project + global installs; project precedence on duplicate slug.
|
|
152
|
+
const [, project_entries] = await read_installed_manifests(false)
|
|
153
|
+
const [, global_entries] = await read_installed_manifests(true)
|
|
154
|
+
const by_slug = new Map()
|
|
155
|
+
for (const e of [...(global_entries || []), ...(project_entries || [])]) by_slug.set(e.slug, e)
|
|
156
|
+
// Sort by slug so command_owner tie-breaks (first-declarer-wins) are stable.
|
|
157
|
+
const all = [...by_slug.values()].sort((a, b) => a.slug.localeCompare(b.slug))
|
|
158
|
+
|
|
159
|
+
// bundled = appears as a dependency of any installed skill.
|
|
160
|
+
const dep_slugs = new Set()
|
|
161
|
+
for (const { manifest } of all) {
|
|
162
|
+
const deps = manifest && manifest.dependencies
|
|
163
|
+
if (deps && typeof deps === 'object') for (const k of Object.keys(deps)) dep_slugs.add(k)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const skills = []
|
|
167
|
+
const command_owner = {}
|
|
168
|
+
for (const { slug, short, manifest } of all) {
|
|
169
|
+
const caps = normalise_capabilities(manifest.capabilities)
|
|
170
|
+
if (caps.length === 0) continue
|
|
171
|
+
const name = manifest.name || short
|
|
172
|
+
skills.push({
|
|
173
|
+
name,
|
|
174
|
+
slug,
|
|
175
|
+
version: manifest.version || null,
|
|
176
|
+
bundled: dep_slugs.has(slug),
|
|
177
|
+
capabilities: caps,
|
|
178
|
+
})
|
|
179
|
+
// Derive command → owner_skill from declared capability.commands.
|
|
180
|
+
// First declarer wins (orthogonal by design; deterministic via slug sort).
|
|
181
|
+
for (const cap of caps) {
|
|
182
|
+
for (const cmd of cap.commands) {
|
|
183
|
+
if (!(cmd in command_owner)) command_owner[cmd] = name
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { skills, command_owner }
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const build_command_registry = (command_owner = {}) => {
|
|
107
191
|
const all = [...COMMANDS]
|
|
108
192
|
if (!all.includes('schema')) all.push('schema')
|
|
109
|
-
return all.map(load_command_schema)
|
|
193
|
+
return all.map(name => ({ ...load_command_schema(name), owner_skill: command_owner[name] || null }))
|
|
110
194
|
}
|
|
111
195
|
|
|
112
196
|
const build_error_code_list = () => ERROR_CODE_LIST.map(code => ({ code }))
|
|
@@ -114,11 +198,12 @@ const build_error_code_list = () => ERROR_CODE_LIST.map(code => ({ code }))
|
|
|
114
198
|
const build_next_step_action_list = () =>
|
|
115
199
|
NEXT_STEP_ACTION_LIST.map(action => ({ action, kind: ACTION_KIND[action] || null }))
|
|
116
200
|
|
|
117
|
-
const build_schema_payload = () => ({
|
|
201
|
+
const build_schema_payload = (ownership = { skills: [], command_owner: {} }) => ({
|
|
118
202
|
envelope_schema_version: ENVELOPE_SCHEMA_VERSION,
|
|
119
203
|
envelope_schema_uri: 'https://schemas.happyskills.dev/envelope/v1.json',
|
|
120
204
|
cli_version: CLI_VERSION,
|
|
121
|
-
commands: build_command_registry(),
|
|
205
|
+
commands: build_command_registry(ownership.command_owner),
|
|
206
|
+
skills: ownership.skills || [],
|
|
122
207
|
error_codes: build_error_code_list(),
|
|
123
208
|
next_step_actions: build_next_step_action_list(),
|
|
124
209
|
next_step_kinds: [...NEXT_STEP_KINDS],
|
|
@@ -149,7 +234,8 @@ const run = (args) => catch_errors('Schema command failed', async () => {
|
|
|
149
234
|
print_help(HELP_TEXT)
|
|
150
235
|
return process.exit(EXIT_CODES.SUCCESS)
|
|
151
236
|
}
|
|
152
|
-
const
|
|
237
|
+
const [, ownership] = await build_ownership()
|
|
238
|
+
const payload = build_schema_payload(ownership || { skills: [], command_owner: {} })
|
|
153
239
|
if (args.flags.json) {
|
|
154
240
|
emit_envelope({ data: payload })
|
|
155
241
|
return
|
|
@@ -170,7 +256,8 @@ const schema = {
|
|
|
170
256
|
envelope_schema_version: 'string',
|
|
171
257
|
envelope_schema_uri: 'string',
|
|
172
258
|
cli_version: 'string',
|
|
173
|
-
commands: 'array<CommandSchema>',
|
|
259
|
+
commands: 'array<CommandSchema & { owner_skill: string|null }>',
|
|
260
|
+
skills: 'array<{ name: string, slug: string, version: string|null, bundled: boolean, capabilities: array<{ id: string|null, summary: string, intents: string[], commands: string[] }> }>',
|
|
174
261
|
error_codes: 'array<{ code: string }>',
|
|
175
262
|
next_step_actions: 'array<{ action: string, kind: string }>',
|
|
176
263
|
next_step_kinds: 'array<string>',
|
|
@@ -182,4 +269,4 @@ const schema = {
|
|
|
182
269
|
],
|
|
183
270
|
}
|
|
184
271
|
|
|
185
|
-
module.exports = { run, schema, build_schema_payload }
|
|
272
|
+
module.exports = { run, schema, build_schema_payload, build_ownership }
|
|
@@ -151,20 +151,28 @@ const NEXT_STEP_BY_ERROR_CODE = Object.freeze({
|
|
|
151
151
|
{ commands: [`npx happyskills pull ${ctx.skill || '<skill>'} --rebase --json`] },
|
|
152
152
|
{ route_to_skill: 'happyskills-sync' }
|
|
153
153
|
),
|
|
154
|
+
// Spec 260606-01 § 4.2 — these recoveries clearly belong to a sibling skill
|
|
155
|
+
// (validate/changelog are publish-pre-flight territory), so name it via
|
|
156
|
+
// route_to_skill. Literal slugs mirror the DRIFT_DETECTED/DIVERGED style:
|
|
157
|
+
// these factories are a static map with no runtime access to the async
|
|
158
|
+
// ownership map, and `happyskills-publish` is the published slug that owns
|
|
159
|
+
// the `validate`/`bump`/`publish`/`release` commands (per the §4.1 map).
|
|
154
160
|
VALIDATION_FAILED: (_msg, ctx = {}) => recovery(
|
|
155
161
|
FIX_VALIDATION_ERRORS,
|
|
156
162
|
'Validation failed. Fix the listed errors and re-run.',
|
|
157
163
|
{
|
|
158
164
|
...(ctx.validation_errors ? { validation_errors: ctx.validation_errors } : {}),
|
|
159
165
|
...(ctx.skill ? { commands: [`npx happyskills validate ${ctx.skill} --json`] } : {}),
|
|
160
|
-
}
|
|
166
|
+
},
|
|
167
|
+
{ route_to_skill: 'happyskills-publish' }
|
|
161
168
|
),
|
|
162
169
|
DEPENDENCY_VALIDATION_FAILED: (_msg, ctx = {}) => recovery(
|
|
163
170
|
FIX_VALIDATION_ERRORS,
|
|
164
171
|
'Dependency validation failed. Fix the listed dependency issues and re-run.',
|
|
165
172
|
{
|
|
166
173
|
...(ctx.validation_errors ? { validation_errors: ctx.validation_errors } : {}),
|
|
167
|
-
}
|
|
174
|
+
},
|
|
175
|
+
{ route_to_skill: 'happyskills-publish' }
|
|
168
176
|
),
|
|
169
177
|
MISSING_CHANGELOG_ENTRY: (_msg, ctx = {}) => recovery(
|
|
170
178
|
PROVIDE_CHANGELOG,
|
|
@@ -173,13 +181,13 @@ const NEXT_STEP_BY_ERROR_CODE = Object.freeze({
|
|
|
173
181
|
...(ctx.target_version ? { target_version: ctx.target_version } : {}),
|
|
174
182
|
...(ctx.current_top_entry ? { current_top_entry: ctx.current_top_entry } : {}),
|
|
175
183
|
},
|
|
176
|
-
{ principal_authorization_required: true }
|
|
184
|
+
{ principal_authorization_required: true, route_to_skill: 'happyskills-publish' }
|
|
177
185
|
),
|
|
178
186
|
CHANGELOG_SOURCE_UNREADABLE: () => recovery(
|
|
179
187
|
PROVIDE_CHANGELOG,
|
|
180
188
|
'CHANGELOG.md could not be read. Restore the file and re-run.',
|
|
181
189
|
{},
|
|
182
|
-
{ principal_authorization_required: true }
|
|
190
|
+
{ principal_authorization_required: true, route_to_skill: 'happyskills-publish' }
|
|
183
191
|
),
|
|
184
192
|
|
|
185
193
|
// Decision-kind defaults
|
|
@@ -226,6 +234,17 @@ const NEXT_STEP_BY_ERROR_CODE = Object.freeze({
|
|
|
226
234
|
...(ctx.candidates ? { candidates: ctx.candidates } : {}),
|
|
227
235
|
}
|
|
228
236
|
),
|
|
237
|
+
// Spec 260606-01 § 4.2 — unresolved merge conflicts are sync territory.
|
|
238
|
+
// Default factory (commands MAY still override with a richer next_step).
|
|
239
|
+
CONFLICT: (_msg, ctx = {}) => decision(
|
|
240
|
+
RESOLVE_CONFLICTS,
|
|
241
|
+
'Unresolved merge conflicts are present. Resolve the conflict markers, then retry the operation.',
|
|
242
|
+
{
|
|
243
|
+
...(ctx.conflict_files ? { conflict_files: ctx.conflict_files } : {}),
|
|
244
|
+
...(ctx.skill ? { commands: [`npx happyskills status ${ctx.skill} --json`] } : {}),
|
|
245
|
+
},
|
|
246
|
+
{ route_to_skill: 'happyskills-sync' }
|
|
247
|
+
),
|
|
229
248
|
|
|
230
249
|
// Confirmation-kind defaults
|
|
231
250
|
LOCAL_EDITS_PRESENT: (_msg, ctx = {}) => confirmation(
|