happyskills 1.9.0 → 1.10.1
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 +17 -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/src/engine/uninstaller.js +33 -2
- package/src/engine/uninstaller.test.js +120 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.10.1] - 2026-06-09
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fix `uninstall` leaving deep transitive dependencies stranded. Orphan pruning ran a single pass, so removing a skill (or kit / constellation core) pruned its direct dependencies but left behind any *grandchild* dependency whose only parent was one of those now-pruned dependencies — it accumulated as unused cruft in `.agents/skills/` and the lock file. Pruning now cascades to a fixed point, removing the full chain of newly-orphaned dependencies. The data-loss guard is preserved: a dependency that any surviving skill still declares (e.g. a shared satellite like `happyskills-design`) is never pruned, and directly-installed (`__root__`) skills are never swept up.
|
|
15
|
+
|
|
16
|
+
## [1.10.0] - 2026-06-06
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- 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.
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- 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.
|
|
26
|
+
|
|
10
27
|
## [1.9.0] - 2026-06-06
|
|
11
28
|
|
|
12
29
|
### 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(
|
package/src/constants.js
CHANGED
|
@@ -36,6 +36,37 @@ const find_orphans = (skills, removed_skill) => {
|
|
|
36
36
|
return orphans
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Cascading orphan detection. `find_orphans` is single-pass by design (its unit
|
|
40
|
+
// contract): it only reports the DIRECT orphans of `removed_skill`. But pruning a
|
|
41
|
+
// direct orphan A can in turn strand A's own private sub-dependency B — a grandchild
|
|
42
|
+
// that nothing else needs once A is gone. A single pass leaves B behind because A is
|
|
43
|
+
// still in the map (so A both counts as a live requester of B and, via its
|
|
44
|
+
// `dependencies`, as a "surviving declarer" of B).
|
|
45
|
+
//
|
|
46
|
+
// This wrapper closes that gap WITHOUT weakening the data-loss guard. It runs
|
|
47
|
+
// `find_orphans` to a fixed point over a shrinking working copy: each round, the
|
|
48
|
+
// orphans found are physically removed from the map, so the next round sees them as
|
|
49
|
+
// gone — both as requesters (`skills[r]` is now false) and as declarers
|
|
50
|
+
// (`declared_by_survivor` no longer finds them). A skill that ANY surviving skill
|
|
51
|
+
// still declares is never pruned, exactly as before — only skills whose entire
|
|
52
|
+
// requirement chain has been removed cascade out. Terminates because every round
|
|
53
|
+
// deletes at least one entry from a finite map.
|
|
54
|
+
const find_orphans_cascading = (skills, removed_skill) => {
|
|
55
|
+
const working = { ...skills }
|
|
56
|
+
delete working[removed_skill]
|
|
57
|
+
|
|
58
|
+
const all_orphans = []
|
|
59
|
+
let round = find_orphans(working, removed_skill)
|
|
60
|
+
while (round.length > 0) {
|
|
61
|
+
for (const name of round) {
|
|
62
|
+
all_orphans.push(name)
|
|
63
|
+
delete working[name]
|
|
64
|
+
}
|
|
65
|
+
round = find_orphans(working, removed_skill)
|
|
66
|
+
}
|
|
67
|
+
return all_orphans
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', async () => {
|
|
40
71
|
const { global: is_global = false, project_root, agents: agents_flag } = options
|
|
41
72
|
|
|
@@ -64,7 +95,7 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
64
95
|
}
|
|
65
96
|
}
|
|
66
97
|
|
|
67
|
-
const orphans =
|
|
98
|
+
const orphans = find_orphans_cascading(all_skills, skill)
|
|
68
99
|
const to_remove = [skill, ...orphans]
|
|
69
100
|
|
|
70
101
|
for (const name of to_remove) {
|
|
@@ -97,4 +128,4 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
97
128
|
return { removed: to_remove, orphans_pruned: orphans }
|
|
98
129
|
})
|
|
99
130
|
|
|
100
|
-
module.exports = { uninstall, find_orphans }
|
|
131
|
+
module.exports = { uninstall, find_orphans, find_orphans_cascading }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { describe, it } = require('node:test')
|
|
2
2
|
const assert = require('node:assert')
|
|
3
|
-
const { find_orphans } = require('./uninstaller')
|
|
3
|
+
const { find_orphans, find_orphans_cascading } = require('./uninstaller')
|
|
4
4
|
|
|
5
5
|
describe('find_orphans', () => {
|
|
6
6
|
it('returns empty when all skills have requesters in the skills map', () => {
|
|
@@ -134,3 +134,122 @@ describe('find_orphans', () => {
|
|
|
134
134
|
assert.ok(result.includes('happyskillsai/happyskills-design'))
|
|
135
135
|
})
|
|
136
136
|
})
|
|
137
|
+
|
|
138
|
+
describe('find_orphans_cascading', () => {
|
|
139
|
+
it('cascades: prunes a grandchild stranded once its only parent is orphaned', () => {
|
|
140
|
+
// Kit K -> A -> B. B is A's private sub-dependency (not listed directly by the
|
|
141
|
+
// kit). Uninstalling K must prune A (direct orphan) AND B (cascaded orphan).
|
|
142
|
+
// This is the exact case single-pass find_orphans leaves stranded.
|
|
143
|
+
const skills = {
|
|
144
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
|
|
145
|
+
'acme/a': { requested_by: ['acme/_kit-x'], dependencies: { 'acme/b': '^1.0.0' } },
|
|
146
|
+
'acme/b': { requested_by: ['acme/_kit-x'], dependencies: {} }
|
|
147
|
+
}
|
|
148
|
+
const result = find_orphans_cascading(skills, 'acme/_kit-x')
|
|
149
|
+
assert.ok(result.includes('acme/a'), 'direct orphan A should be pruned')
|
|
150
|
+
assert.ok(result.includes('acme/b'), 'cascaded orphan B should be pruned')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('still protects a shared dependency a surviving skill declares (no over-pruning under cascade)', () => {
|
|
154
|
+
// The data-loss guard must survive the cascade: design is declared by the
|
|
155
|
+
// surviving happyskills, so uninstalling create-release-skill must NOT prune it
|
|
156
|
+
// even though we now iterate to a fixed point.
|
|
157
|
+
const skills = {
|
|
158
|
+
'happyskillsai/happyskills': {
|
|
159
|
+
requested_by: ['__root__'],
|
|
160
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.1.0' }
|
|
161
|
+
},
|
|
162
|
+
'nicolasdao/create-release-skill': {
|
|
163
|
+
requested_by: ['__root__'],
|
|
164
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
|
|
165
|
+
},
|
|
166
|
+
'happyskillsai/happyskills-design': {
|
|
167
|
+
requested_by: ['nicolasdao/create-release-skill']
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const result = find_orphans_cascading(skills, 'nicolasdao/create-release-skill')
|
|
171
|
+
assert.ok(!result.includes('happyskillsai/happyskills-design'))
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('never prunes a user-installed (__root__) skill, even mid-cascade', () => {
|
|
175
|
+
// K -> A, but A is ALSO directly installed by the user. Removing K orphans
|
|
176
|
+
// nothing the user still wants: A is __root__-anchored and stays.
|
|
177
|
+
const skills = {
|
|
178
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
|
|
179
|
+
'acme/a': { requested_by: ['__root__', 'acme/_kit-x'], dependencies: {} }
|
|
180
|
+
}
|
|
181
|
+
const result = find_orphans_cascading(skills, 'acme/_kit-x')
|
|
182
|
+
assert.deepStrictEqual(result, [])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('halts the cascade at a still-shared mid-chain dependency', () => {
|
|
186
|
+
// K -> A -> B, but B is ALSO required by an unrelated surviving skill S.
|
|
187
|
+
// Removing K prunes A, but B must stay because S still needs it.
|
|
188
|
+
const skills = {
|
|
189
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
|
|
190
|
+
'acme/a': { requested_by: ['acme/_kit-x'], dependencies: { 'acme/b': '^1.0.0' } },
|
|
191
|
+
'acme/s': { requested_by: ['__root__'], dependencies: { 'acme/b': '^1.0.0' } },
|
|
192
|
+
'acme/b': { requested_by: ['acme/_kit-x', 'acme/s'], dependencies: {} }
|
|
193
|
+
}
|
|
194
|
+
const result = find_orphans_cascading(skills, 'acme/_kit-x')
|
|
195
|
+
assert.ok(result.includes('acme/a'), 'A is orphaned')
|
|
196
|
+
assert.ok(!result.includes('acme/b'), 'B is still needed by surviving acme/s')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('constellation: uninstalling the core prunes bundled satellites, keeps shared + opt-in ones', () => {
|
|
200
|
+
// Models the real happyskills constellation: the core declares 5 bundled
|
|
201
|
+
// satellites as dependencies. happyskills-design is ALSO declared by a surviving
|
|
202
|
+
// skill (create-release-skill). collab + stats are OPT-IN — installed directly
|
|
203
|
+
// (__root__), never dependencies of the core.
|
|
204
|
+
const skills = {
|
|
205
|
+
'happyskillsai/happyskills': {
|
|
206
|
+
requested_by: ['__root__'],
|
|
207
|
+
dependencies: {
|
|
208
|
+
'happyskillsai/happyskills-design': '^0.1.0',
|
|
209
|
+
'happyskillsai/happyskills-publish': '^0.1.0',
|
|
210
|
+
'happyskillsai/happyskills-sync': '^0.1.0',
|
|
211
|
+
'happyskillsai/happyskills-search': '^0.1.0',
|
|
212
|
+
'happyskillsai/happyskills-help': '^0.1.0'
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
'nicolasdao/create-release-skill': {
|
|
216
|
+
requested_by: ['__root__'],
|
|
217
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
|
|
218
|
+
},
|
|
219
|
+
'happyskillsai/happyskills-design': { requested_by: ['happyskillsai/happyskills', 'nicolasdao/create-release-skill'], dependencies: {} },
|
|
220
|
+
'happyskillsai/happyskills-publish': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
221
|
+
'happyskillsai/happyskills-sync': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
222
|
+
'happyskillsai/happyskills-search': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
223
|
+
'happyskillsai/happyskills-help': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
224
|
+
'happyskillsai/happyskills-collab': { requested_by: ['__root__'], dependencies: {} },
|
|
225
|
+
'happyskillsai/happyskills-stats': { requested_by: ['__root__'], dependencies: {} }
|
|
226
|
+
}
|
|
227
|
+
const result = find_orphans_cascading(skills, 'happyskillsai/happyskills').sort()
|
|
228
|
+
assert.deepStrictEqual(result, [
|
|
229
|
+
'happyskillsai/happyskills-help',
|
|
230
|
+
'happyskillsai/happyskills-publish',
|
|
231
|
+
'happyskillsai/happyskills-search',
|
|
232
|
+
'happyskillsai/happyskills-sync'
|
|
233
|
+
])
|
|
234
|
+
// design is shared (create-release-skill still declares it) → kept
|
|
235
|
+
assert.ok(!result.includes('happyskillsai/happyskills-design'))
|
|
236
|
+
// collab + stats are opt-in (__root__) → never swept up by a core uninstall
|
|
237
|
+
assert.ok(!result.includes('happyskillsai/happyskills-collab'))
|
|
238
|
+
assert.ok(!result.includes('happyskillsai/happyskills-stats'))
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('matches find_orphans for a flat kit (no nested sub-dependencies)', () => {
|
|
242
|
+
// The common curated-kit case: K directly lists A, B, C and none depend on each
|
|
243
|
+
// other. Single-pass and cascading must agree — cascade changes nothing here.
|
|
244
|
+
const skills = {
|
|
245
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1', 'acme/b': '^1', 'acme/c': '^1' } },
|
|
246
|
+
'acme/a': { requested_by: ['acme/_kit-x'], dependencies: {} },
|
|
247
|
+
'acme/b': { requested_by: ['acme/_kit-x'], dependencies: {} },
|
|
248
|
+
'acme/c': { requested_by: ['acme/_kit-x'], dependencies: {} }
|
|
249
|
+
}
|
|
250
|
+
const single = find_orphans(skills, 'acme/_kit-x').sort()
|
|
251
|
+
const cascaded = find_orphans_cascading(skills, 'acme/_kit-x').sort()
|
|
252
|
+
assert.deepStrictEqual(cascaded, ['acme/a', 'acme/b', 'acme/c'])
|
|
253
|
+
assert.deepStrictEqual(cascaded, single)
|
|
254
|
+
})
|
|
255
|
+
})
|