happyskills 1.16.1 → 1.16.3

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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.16.3] - 2026-07-03
11
+
12
+ ### Fixed
13
+
14
+ - `happyskills install` no longer reports a skill as "already installed" when its **declared dependency closure is not materialized on disk**. The pre-resolve skip check previously inspected only the requested skill's own directory + integrity, so a lock entry carrying a populated `dependencies` map whose skills were never installed (e.g. a kit entry written by `publish`, which records `manifest.dependencies` but installs nothing) would short-circuit every `install` — the missing transitive dependencies were never backfilled and the lock stayed permanently out of sync with `.agents/skills/`. The skip check now walks the locked dependency graph (cycle-safe, transitive) via `dependency_closure_satisfied` and only skips when every reachable dependency is both locked **and** present on disk; otherwise it declines the skip and re-resolves so the missing skills are installed. This makes `install` self-healing: re-running it repairs a partial lock without needing `--fresh`.
15
+
16
+ ## [1.16.2] - 2026-07-03
17
+
18
+ ### Changed
19
+
20
+ - `happyskills init` and `happyskills fork` no longer scaffold a `keywords` field in the generated `skill.json`. `keywords` is a deprecated, platform-ignored manifest field — discovery labels are derived server-side from the skill's `description` + `SKILL.md` (the platform-owned taxonomy), so an author-supplied keyword list has no effect on search, ranking, or filtering. The scaffold previously emitted an empty `keywords: []`, which baited enrichment agents into populating a no-op field; omitting it removes the bait. The field remains accepted on publish for backward compatibility.
21
+ - `happyskills update --all` now checks **every installed skill**, not just top-level (`__root__`) ones. Previously a bundled dependency — e.g. a HappySkills family satellite (`happyskills-design/publish/sync/search/help`) installed transitively under `happyskills` — was skipped by `--all` and only refreshed when its parent happened to be reinstalled, so a satellite that advanced on the registry while its parent didn't stayed stale even though `check` and the daily update nudge flagged it. `update --all` now mirrors `check` and the nudge and refreshes those drifted transitive skills directly. `update <owner/name>` and the `--all-scopes` per-scope behavior are unchanged.
22
+
10
23
  ## [1.16.1] - 2026-07-02
11
24
 
12
25
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.16.1",
3
+ "version": "1.16.3",
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)",
@@ -83,12 +83,14 @@ const run = (args) => catch_errors('Fork failed', async () => {
83
83
  const [, original_manifest] = await read_manifest(dest)
84
84
  const is_kit = (original_manifest?.type) === SKILL_TYPES.KIT
85
85
  const forked_name = is_kit && !name.startsWith(KIT_PREFIX) ? `${KIT_PREFIX}${name}` : name
86
+ // No `keywords` field — it is a deprecated, platform-ignored skill.json field
87
+ // (docs/skill-format.md § 4.3). A fork starts clean; discovery labels are derived
88
+ // server-side from description + SKILL.md, not from an author-supplied keyword list.
86
89
  const forked_manifest = {
87
90
  name: forked_name,
88
91
  version: '0.1.0',
89
92
  type: is_kit ? SKILL_TYPES.KIT : SKILL_TYPES.SKILL,
90
93
  description: '',
91
- keywords: [],
92
94
  forked_from: {
93
95
  repo: skill,
94
96
  version: latest_version,
@@ -58,11 +58,15 @@ Add the skills this kit bundles and why they work well together.
58
58
  Describe the project type or workflow this kit is designed for.
59
59
  `
60
60
 
61
+ // Note: no `keywords` field. It is a deprecated skill.json field (docs/skill-format.md
62
+ // § 4.3) — the platform-owned taxonomy derives discovery labels server-side from the
63
+ // skill's description + SKILL.md, so an author-supplied keyword list is ignored by
64
+ // search, ranking, and filtering. Emitting an empty `keywords: []` here only baited the
65
+ // enrichment agent into populating a field with no effect; omitting it removes the bait.
61
66
  const create_manifest = (name, is_kit = false) => ({
62
67
  name,
63
68
  version: '0.1.0',
64
69
  description: '',
65
- keywords: [],
66
70
  type: is_kit ? SKILL_TYPES.KIT : SKILL_TYPES.SKILL,
67
71
  dependencies: {}
68
72
  })
@@ -181,4 +185,4 @@ const schema = {
181
185
  ],
182
186
  }
183
187
 
184
- module.exports = { run, schema }
188
+ module.exports = { run, schema, create_manifest }
@@ -0,0 +1,29 @@
1
+ 'use strict'
2
+ const { describe, it } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+
5
+ const { create_manifest } = require('./init')
6
+
7
+ // `keywords` is a deprecated skill.json field — not read by search, ranking, or
8
+ // the platform-owned taxonomy (docs/skill-format.md § 4.3, docs/taxonomy.md § 1).
9
+ // The scaffold must NOT emit an empty `keywords` key: a present-but-empty field is
10
+ // bait that leads the enrichment agent to populate a field the platform ignores.
11
+ describe('create_manifest', () => {
12
+ it('does not emit a keywords key for a skill scaffold', () => {
13
+ const manifest = create_manifest('my-skill')
14
+ assert.ok(!('keywords' in manifest), 'scaffold must not include the deprecated keywords field')
15
+ })
16
+
17
+ it('does not emit a keywords key for a kit scaffold', () => {
18
+ const manifest = create_manifest('_kit-my-kit', true)
19
+ assert.ok(!('keywords' in manifest), 'kit scaffold must not include the deprecated keywords field')
20
+ })
21
+
22
+ it('still emits the fields the enrichment workflow fills', () => {
23
+ const manifest = create_manifest('my-skill')
24
+ assert.strictEqual(manifest.name, 'my-skill')
25
+ assert.strictEqual(manifest.version, '0.1.0')
26
+ assert.strictEqual(manifest.description, '')
27
+ assert.deepStrictEqual(manifest.dependencies, {})
28
+ })
29
+ })
@@ -25,7 +25,7 @@ Arguments:
25
25
  owner/skill Update a specific skill (optional)
26
26
 
27
27
  Options:
28
- --all Update all installed root-level skills
28
+ --all Update all installed skills (roots and their dependencies)
29
29
  --all-scopes Update BOTH project-local and global skills (in that order),
30
30
  reporting per-scope results. Overrides -g.
31
31
  --force Re-install regardless of version. Also overwrites skills
@@ -66,6 +66,18 @@ const confirm_prompt = (question) => new Promise((resolve) => {
66
66
  })
67
67
  })
68
68
 
69
+ // Selects which locked skills `update` should consider. With an explicit target, only
70
+ // that skill (if locked). For --all, EVERY installed skill — not just top-level
71
+ // (__root__) ones — so a bundled family satellite installed as a transitive dependency
72
+ // (e.g. happyskills-help under happyskills) is refreshed too. Without this, `update --all`
73
+ // would report staleness (via check/the nudge) it could not resolve. Keeps --all
74
+ // consistent with `check` and the passive drift nudge. A null value is a removed-skill
75
+ // tombstone — skip it. Pure + exported for unit testing.
76
+ const select_update_candidates = (skills, target_skill) =>
77
+ target_skill
78
+ ? (skills && skills[target_skill] ? [[target_skill, skills[target_skill]]] : [])
79
+ : Object.entries(skills || {}).filter(([, d]) => d && typeof d === 'object')
80
+
69
81
  /**
70
82
  * Run the full update flow for ONE scope (project-local or global) and return
71
83
  * `{ scope, data }` — where `data` is exactly the JSON `data` payload that
@@ -89,12 +101,8 @@ const run_scope = (scope_is_global, ctx) => catch_errors('Update scope failed',
89
101
  const skills = get_all_locked_skills(lock_data)
90
102
  const base_dir = skills_dir(scope_is_global, project_root)
91
103
 
92
- // Determine which skills to consider.
93
- // For --all, only root-level skills (transitive deps come along through their parents).
94
- // For a specific target, allow any locked skill.
95
- const candidates = target_skill
96
- ? (skills[target_skill] ? [[target_skill, skills[target_skill]]] : [])
97
- : Object.entries(skills).filter(([, data]) => data && data.requested_by?.includes('__root__'))
104
+ // Determine which skills to consider (see select_update_candidates).
105
+ const candidates = select_update_candidates(skills, target_skill)
98
106
 
99
107
  if (candidates.length === 0) {
100
108
  // A missing target is only a hard error in single-scope mode. Across
@@ -433,4 +441,4 @@ const schema = {
433
441
  ]
434
442
  }
435
443
 
436
- module.exports = { run, schema }
444
+ module.exports = { run, schema, select_update_candidates }
@@ -0,0 +1,36 @@
1
+ const { test } = require('node:test')
2
+ const assert = require('node:assert')
3
+
4
+ const { select_update_candidates } = require('./update')
5
+
6
+ // A normal-user family install: core is __root__, the bundled satellites are transitive
7
+ // dependencies of core (requested_by: [core]). This is what a plain `happyskills setup`
8
+ // produces — the case where the __root__-only filter used to hide the satellites.
9
+ const FAMILY_LOCK = {
10
+ 'happyskillsai/happyskills': { version: '2.5.4', requested_by: ['__root__'] },
11
+ 'happyskillsai/happyskills-help': { version: '0.8.2', requested_by: ['happyskillsai/happyskills'] },
12
+ 'happyskillsai/happyskills-sync': { version: '0.6.3', requested_by: ['happyskillsai/happyskills'] },
13
+ }
14
+
15
+ test('update --all considers transitive family satellites, not just __root__ skills', () => {
16
+ // Coherence fix: `check` and the passive nudge already watch every installed skill;
17
+ // `update --all` must too, or it reports staleness it cannot resolve via the obvious verb.
18
+ const names = select_update_candidates(FAMILY_LOCK, undefined).map(([n]) => n)
19
+ assert.ok(names.includes('happyskillsai/happyskills-help'),
20
+ '`update --all` must be able to refresh a bundled satellite installed as a transitive dependency')
21
+ assert.ok(names.includes('happyskillsai/happyskills'), 'root skills are still included')
22
+ })
23
+
24
+ test('update <target> selects only that skill (root or transitive), or none if unlocked', () => {
25
+ assert.deepStrictEqual(
26
+ select_update_candidates(FAMILY_LOCK, 'happyskillsai/happyskills-help').map(([n]) => n),
27
+ ['happyskillsai/happyskills-help'])
28
+ assert.deepStrictEqual(select_update_candidates(FAMILY_LOCK, 'acme/not-installed'), [])
29
+ })
30
+
31
+ test('select_update_candidates skips null (removed-skill) tombstones', () => {
32
+ const lock = { 'acme/root': { version: '1.0.0', requested_by: ['__root__'] }, 'acme/removed': null }
33
+ const names = select_update_candidates(lock, undefined).map(([n]) => n)
34
+ assert.ok(names.includes('acme/root'))
35
+ assert.ok(!names.includes('acme/removed'))
36
+ })
@@ -8,7 +8,7 @@ const { check_system_dependencies } = require('./system_deps')
8
8
  const { hash_directory, verify_integrity } = require('../lock/integrity')
9
9
  const { read_lock, get_locked_skill } = require('../lock/reader')
10
10
  const { write_lock, update_lock_skills } = require('../lock/writer')
11
- const { verify_lock_disk_consistency } = require('../lock/verify')
11
+ const { verify_lock_disk_consistency, dependency_closure_satisfied } = require('../lock/verify')
12
12
  const { skills_dir, tmp_dir, skill_install_dir, lock_root } = require('../config/paths')
13
13
  const { ensure_dir, remove_dir, file_exists, read_json } = require('../utils/fs')
14
14
  const { SKILL_JSON, SKILL_TYPES } = require('../constants')
@@ -110,7 +110,18 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
110
110
  const [, exists] = await file_exists(install_dir)
111
111
  if (exists) {
112
112
  const [, valid] = locked.integrity ? await verify_integrity(install_dir, locked.integrity) : [null, true]
113
- if (valid !== false) {
113
+ // The skill itself is present and intact — but "already installed"
114
+ // must also mean its declared dependency closure is materialized on
115
+ // disk. A lock entry can carry a `dependencies` map whose skills were
116
+ // never installed (e.g. a kit entry written by `publish`, which
117
+ // records deps but installs nothing). Skipping such an entry leaves
118
+ // the lock and `.agents/skills/` permanently out of sync. If any
119
+ // transitive dep is missing, decline the skip and fall through to
120
+ // resolution so the missing deps get backfilled.
121
+ const [, closure] = valid !== false
122
+ ? await dependency_closure_satisfied(lock_data, skill, (s) => skill_install_dir(base_dir, s.split('/')[1]))
123
+ : [null, { satisfied: false, missing: [] }]
124
+ if (valid !== false && closure && closure.satisfied) {
114
125
  // Verify and repair symlinks even for already-installed skills (skip kits — not agent-invocable)
115
126
  if (agents.length > 0 && locked.type !== SKILL_TYPES.KIT) {
116
127
  const name = skill.split('/')[1]
@@ -134,7 +145,8 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
134
145
  forced: []
135
146
  }
136
147
  }
137
- // integrity mismatch fall through to reinstall at locked version
148
+ // integrity mismatch or an incomplete dependency closure fall
149
+ // through to re-resolve and reinstall/backfill at the locked version
138
150
  }
139
151
  }
140
152
  }
@@ -0,0 +1,122 @@
1
+ 'use strict'
2
+ /**
3
+ * Integration test — install must not report "already installed" for a skill whose
4
+ * locked dependency closure is not materialized on disk.
5
+ *
6
+ * The production bug: a `skills-lock.json` recorded a kit as an installed root
7
+ * skill (`requested_by: ['__root__']`, valid integrity, present on disk) with a
8
+ * populated `dependencies` map — but those dependencies were never resolved into
9
+ * their own lock entries and never landed under `.agents/skills/`. Every
10
+ * subsequent `install` then hit the pre-resolve skip check, which inspected only
11
+ * the kit's own directory + integrity and returned "already installed" BEFORE
12
+ * dependency resolution ran. The missing deps were never backfilled: the lock and
13
+ * the on-disk `.agents/skills/` were permanently out of sync.
14
+ *
15
+ * These tests run the REAL CLI with an unreachable registry (localhost:0). That
16
+ * isolates the skip decision from the network:
17
+ * - If the skip fires, the command SUCCEEDS with "already installed" and never
18
+ * touches the network (the buggy behavior).
19
+ * - If the skip is correctly declined, the command falls through to resolution
20
+ * and FAILS on the unreachable registry (the fixed behavior).
21
+ * We assert on that difference, plus the inverse: a fully-materialized closure
22
+ * must still skip (no over-triggering, fast-path preserved).
23
+ */
24
+ const { describe, it } = require('node:test')
25
+ const assert = require('node:assert/strict')
26
+ const { spawnSync } = require('child_process')
27
+ const fs = require('fs')
28
+ const os = require('os')
29
+ const path = require('path')
30
+
31
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
32
+ const NODE = process.execPath
33
+
34
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-dep-closure-test-'))
35
+ const run = (args, opts) => {
36
+ const result = spawnSync(NODE, [CLI, ...args, '--text'], {
37
+ env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...(opts?.env || {}) },
38
+ encoding: 'utf-8',
39
+ timeout: 15000,
40
+ cwd: opts?.cwd
41
+ })
42
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
43
+ }
44
+
45
+ // Write a skill directory under <root>/.agents/skills/<name> and return the dir.
46
+ const write_skill = (root, name, manifest) => {
47
+ const dir = path.join(root, '.agents', 'skills', name)
48
+ fs.mkdirSync(dir, { recursive: true })
49
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: dep-closure test skill\n---\nbody\n`)
50
+ fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest, null, '\t'))
51
+ return dir
52
+ }
53
+
54
+ // Build a project whose lock records `kit` as a __root__ skill (valid integrity,
55
+ // on disk) with the given dependency map. `present_deps` names the deps that are
56
+ // ALSO created on disk + locked; any dep in the map but not in present_deps is
57
+ // declared-but-missing.
58
+ const scaffold = async ({ kit, kit_deps, present_deps }) => {
59
+ const { hash_directory, clear_integrity_cache } = require('../lock/integrity')
60
+ const root = make_tmp()
61
+ const skills = {}
62
+
63
+ const kit_dir = write_skill(root, kit, {
64
+ name: kit, version: '1.0.0', type: 'kit',
65
+ description: 'dep-closure test kit',
66
+ dependencies: Object.fromEntries(kit_deps.map(d => [`acme/${d}`, '*']))
67
+ })
68
+ clear_integrity_cache()
69
+ const [, kit_integrity] = await hash_directory(kit_dir)
70
+ skills[`acme/${kit}`] = {
71
+ version: '1.0.0', ref: 'refs/tags/v1.0.0', commit: 'c', integrity: kit_integrity,
72
+ base_commit: 'c', base_integrity: kit_integrity,
73
+ requested_by: ['__root__'],
74
+ dependencies: Object.fromEntries(kit_deps.map(d => [`acme/${d}`, '*'])),
75
+ type: 'kit'
76
+ }
77
+
78
+ for (const dep of present_deps) {
79
+ const dep_dir = write_skill(root, dep, { name: dep, version: '1.0.0', type: 'skill', description: 'dep' })
80
+ clear_integrity_cache()
81
+ const [, dep_integrity] = await hash_directory(dep_dir)
82
+ skills[`acme/${dep}`] = {
83
+ version: '1.0.0', ref: 'refs/tags/v1.0.0', commit: 'c', integrity: dep_integrity,
84
+ base_commit: 'c', base_integrity: dep_integrity,
85
+ requested_by: [`acme/${kit}`], dependencies: {}, type: 'skill'
86
+ }
87
+ }
88
+
89
+ fs.writeFileSync(
90
+ path.join(root, 'skills-lock.json'),
91
+ JSON.stringify({ lockVersion: 2, generatedAt: new Date().toISOString(), skills }, null, '\t')
92
+ )
93
+ return { root, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
94
+ }
95
+
96
+ describe('install — dependency-closure skip guard', () => {
97
+ it('does NOT report "already installed" when a locked dependency was never materialized', async () => {
98
+ // kit declares acme/dep-a but dep-a is neither locked nor on disk.
99
+ const ctx = await scaffold({ kit: 'thekit', kit_deps: ['dep-a'], present_deps: [] })
100
+ try {
101
+ const { code, stdout, stderr } = run(['install', 'acme/thekit'], { cwd: ctx.root })
102
+ const out = stdout + stderr
103
+ // Fixed behavior: the skip is declined, so install falls through to
104
+ // resolution and fails against the unreachable registry.
105
+ assert.doesNotMatch(out, /already installed/i,
106
+ 'must not claim already-installed while a declared dependency is missing')
107
+ assert.notStrictEqual(code, 0, 'must attempt resolution (and fail on the unreachable registry), not skip')
108
+ } finally { ctx.cleanup() }
109
+ })
110
+
111
+ it('still skips (already installed, no network) when the whole closure is present', async () => {
112
+ // Regression guard: a fully-materialized closure must keep the fast path —
113
+ // skip without any resolution/network call, even with the registry down.
114
+ const ctx = await scaffold({ kit: 'fullkit', kit_deps: ['dep-a'], present_deps: ['dep-a'] })
115
+ try {
116
+ const { code, stdout, stderr } = run(['install', 'acme/fullkit'], { cwd: ctx.root })
117
+ const out = stdout + stderr
118
+ assert.match(out, /already installed/i, 'a complete closure must still short-circuit')
119
+ assert.strictEqual(code, 0, 'skip path must not touch the network')
120
+ } finally { ctx.cleanup() }
121
+ })
122
+ })
@@ -118,6 +118,58 @@ const detect_ahead_state = (lock_entry, install_dir) => catch_errors('Failed to
118
118
  }
119
119
  })
120
120
 
121
+ // dependency_closure_satisfied — walk the locked dependency graph starting at
122
+ // `skill` and confirm every reachable skill is BOTH present in the lock AND
123
+ // materialized on disk. Returns { satisfied: bool, missing: [full skill names] }.
124
+ //
125
+ // This closes the "lock declares deps that were never installed" gap. A lock
126
+ // entry can carry a populated `dependencies` map whose referenced skills have no
127
+ // lock entry and no install directory — e.g. an entry authored by `publish`
128
+ // (which records `manifest.dependencies` but installs nothing), or any install
129
+ // that recorded a root skill while its transitive deps were skipped. In that
130
+ // state the lock and `.agents/skills/` are out of sync.
131
+ //
132
+ // The install skip-check ("already installed") uses this to decide whether a
133
+ // locked-and-present root is actually COMPLETE. If any transitive dependency is
134
+ // missing, the caller must decline the skip and re-resolve so the missing deps
135
+ // get backfilled — the lock must always match what is on disk.
136
+ //
137
+ // `resolve_dir(full_skill_name)` maps `owner/name` to its install directory.
138
+ // Cycle-safe via a visited set. A dependency that is referenced but absent from
139
+ // the lock, or locked but missing on disk, is reported in `missing`.
140
+ const dependency_closure_satisfied = (lock_data, skill, resolve_dir) => catch_errors('Failed to verify dependency closure', async () => {
141
+ const skills = (lock_data && lock_data.skills) || {}
142
+ const missing = []
143
+ const visited = new Set()
144
+ const queue = [skill]
145
+
146
+ while (queue.length > 0) {
147
+ const current = queue.shift()
148
+ if (visited.has(current)) continue
149
+ visited.add(current)
150
+
151
+ const entry = skills[current]
152
+ if (!entry) {
153
+ // Referenced as a dependency but never locked.
154
+ missing.push(current)
155
+ continue
156
+ }
157
+
158
+ const [, dir_present] = await file_exists(resolve_dir(current))
159
+ if (!dir_present) {
160
+ // Locked but not materialized on disk.
161
+ missing.push(current)
162
+ continue
163
+ }
164
+
165
+ for (const dep_name of Object.keys(entry.dependencies || {})) {
166
+ if (!visited.has(dep_name)) queue.push(dep_name)
167
+ }
168
+ }
169
+
170
+ return { satisfied: missing.length === 0, missing }
171
+ })
172
+
121
173
  // Plain-language description of a drift result, suitable for the principal-facing
122
174
  // table cell (status column). Returns null when ok or ahead.
123
175
  const describe_drift = (verify_result) => {
@@ -179,5 +231,6 @@ module.exports = {
179
231
  detect_ahead_state,
180
232
  describe_drift,
181
233
  classify_lock_disk,
182
- parse_changelog_top_version
234
+ parse_changelog_top_version,
235
+ dependency_closure_satisfied
183
236
  }
@@ -10,7 +10,8 @@ const {
10
10
  detect_ahead_state,
11
11
  describe_drift,
12
12
  classify_lock_disk,
13
- parse_changelog_top_version
13
+ parse_changelog_top_version,
14
+ dependency_closure_satisfied
14
15
  } = require('./verify')
15
16
 
16
17
  const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-verify-test-'))
@@ -276,6 +277,113 @@ describe('classify_lock_disk', () => {
276
277
  })
277
278
  })
278
279
 
280
+ describe('dependency_closure_satisfied', () => {
281
+ // A skill directory is "present" iff it exists on disk. We model the disk with a
282
+ // temp root and create a sub-dir per skill that is supposed to be installed.
283
+ const make_root = () => make_tmp()
284
+ const present = (root, skill) => {
285
+ const dir = path.join(root, skill.replace('/', '__'))
286
+ fs.mkdirSync(dir, { recursive: true })
287
+ return dir
288
+ }
289
+ const resolver = (root) => (skill) => path.join(root, skill.replace('/', '__'))
290
+
291
+ const lock = (skills) => ({ lockVersion: 2, skills })
292
+
293
+ it('is satisfied when the root and its whole locked dep closure are present on disk', async () => {
294
+ const root = make_root()
295
+ try {
296
+ present(root, 'acme/kit')
297
+ present(root, 'acme/dep-a')
298
+ present(root, 'acme/dep-b')
299
+ const data = lock({
300
+ 'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*', 'acme/dep-b': '*' } },
301
+ 'acme/dep-a': { version: '1.0.0', dependencies: {} },
302
+ 'acme/dep-b': { version: '1.0.0', dependencies: {} }
303
+ })
304
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
305
+ assert.strictEqual(err, null)
306
+ assert.deepEqual(result, { satisfied: true, missing: [] })
307
+ } finally { cleanup(root) }
308
+ })
309
+
310
+ it('is NOT satisfied when a declared dependency has no lock entry (the kit-deps-never-installed bug)', async () => {
311
+ // This is the exact production shape: the kit is locked + on disk, its
312
+ // `dependencies` map is populated, but those deps were never resolved into
313
+ // their own lock entries and never landed on disk.
314
+ const root = make_root()
315
+ try {
316
+ present(root, 'acme/kit')
317
+ const data = lock({
318
+ 'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*', 'acme/dep-b': '*' } }
319
+ })
320
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
321
+ assert.strictEqual(err, null)
322
+ assert.strictEqual(result.satisfied, false)
323
+ assert.deepEqual(result.missing.sort(), ['acme/dep-a', 'acme/dep-b'])
324
+ } finally { cleanup(root) }
325
+ })
326
+
327
+ it('is NOT satisfied when a dependency is locked but its directory is missing on disk', async () => {
328
+ const root = make_root()
329
+ try {
330
+ present(root, 'acme/kit')
331
+ // acme/dep-a is locked but NOT created on disk.
332
+ const data = lock({
333
+ 'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*' } },
334
+ 'acme/dep-a': { version: '1.0.0', dependencies: {} }
335
+ })
336
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
337
+ assert.strictEqual(err, null)
338
+ assert.strictEqual(result.satisfied, false)
339
+ assert.deepEqual(result.missing, ['acme/dep-a'])
340
+ } finally { cleanup(root) }
341
+ })
342
+
343
+ it('walks transitively — a missing dep-of-a-dep still fails the closure', async () => {
344
+ const root = make_root()
345
+ try {
346
+ present(root, 'acme/kit')
347
+ present(root, 'acme/dep-a')
348
+ // acme/dep-a depends on acme/deep, which is neither locked nor on disk.
349
+ const data = lock({
350
+ 'acme/kit': { version: '1.0.0', dependencies: { 'acme/dep-a': '*' } },
351
+ 'acme/dep-a': { version: '1.0.0', dependencies: { 'acme/deep': '*' } }
352
+ })
353
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/kit', resolver(root))
354
+ assert.strictEqual(err, null)
355
+ assert.strictEqual(result.satisfied, false)
356
+ assert.deepEqual(result.missing, ['acme/deep'])
357
+ } finally { cleanup(root) }
358
+ })
359
+
360
+ it('is cycle-safe (A → B → A) and does not loop forever', async () => {
361
+ const root = make_root()
362
+ try {
363
+ present(root, 'acme/a')
364
+ present(root, 'acme/b')
365
+ const data = lock({
366
+ 'acme/a': { version: '1.0.0', dependencies: { 'acme/b': '*' } },
367
+ 'acme/b': { version: '1.0.0', dependencies: { 'acme/a': '*' } }
368
+ })
369
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/a', resolver(root))
370
+ assert.strictEqual(err, null)
371
+ assert.deepEqual(result, { satisfied: true, missing: [] })
372
+ } finally { cleanup(root) }
373
+ })
374
+
375
+ it('is satisfied for a leaf skill with no dependencies', async () => {
376
+ const root = make_root()
377
+ try {
378
+ present(root, 'acme/solo')
379
+ const data = lock({ 'acme/solo': { version: '1.0.0', dependencies: {} } })
380
+ const [err, result] = await dependency_closure_satisfied(data, 'acme/solo', resolver(root))
381
+ assert.strictEqual(err, null)
382
+ assert.deepEqual(result, { satisfied: true, missing: [] })
383
+ } finally { cleanup(root) }
384
+ })
385
+ })
386
+
279
387
  describe('describe_drift', () => {
280
388
  it('returns null for ok results', () => {
281
389
  assert.strictEqual(describe_drift({ ok: true }), null)
@@ -56,7 +56,7 @@ const select_drift_candidates = (skills) =>
56
56
 
57
57
  // Background refresh — NOT awaited, all errors swallowed. Reads the lock directly
58
58
  // (NOT via lock/reader.read_lock, which prints a stderr warning on version mismatch
59
- // — unwanted noise from a silent background task), batch-checks root skills against
59
+ // — unwanted noise from a silent background task), batch-checks every installed skill against
60
60
  // the registry, and records ONLY the genuinely-outdated ones. It never reads the
61
61
  // working tree, so it can never mistake an `ahead` (locally-bumped, unpublished) or
62
62
  // regression-drifted skill for "outdated": a local bump leaves base_commit untouched,