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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.9.0",
3
+ "version": "1.10.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)",
@@ -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
+ })
@@ -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
- const build_command_registry = () => {
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 payload = build_schema_payload()
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(
package/src/constants.js CHANGED
@@ -51,6 +51,7 @@ const COMMANDS = [
51
51
  'visibility',
52
52
  'list',
53
53
  'search',
54
+ 'resolve',
54
55
  'star',
55
56
  'unstar',
56
57
  'check',