happyskills 0.43.1 → 0.44.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 CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.44.1] - 2026-05-13
11
+
12
+ ### Changed
13
+ - `diff` no longer hard-blocks when the target skill has lock-vs-disk drift. Previously, running `happyskills diff <skill>` against a drifted skill threw a `UsageError` telling the user to run `happyskills install <skill> --fresh` before diffing — but `--fresh` overwrites the on-disk content, destroying the very local state the user was trying to inspect. Drift is exactly the case where diff is most useful as a diagnostic tool. The command now prints a one-line warning (`<skill> has drift: lock <X>, disk <Y>. Diff is shown against the lock-recorded base (<X>).`) and proceeds with the diff. JSON output gains a top-level `drift` field (same `{ reason, lock_version, disk_version }` shape used elsewhere) in `local` and `full` modes, or `null` when clean. `--remote` mode skips the drift probe entirely since it reads nothing from disk. Other drift-aware commands are unchanged — `status`/`check`/`list` still surface drift in their reports, `update` still skips drifted skills to avoid clobbering local work, and the post-write self-checks in `install`/`pull`/`bump`/`publish`/`convert` still throw on inconsistency.
14
+
15
+ ## [0.44.0] - 2026-05-12
16
+
17
+ ### Added
18
+ - Detect lock-vs-disk drift across every CLI command that reads or reports installed-skill state. A new `drift` status is reported whenever the version recorded in `skills-lock.json` does not agree with the version field in the skill's on-disk `skill.json` (or when the install directory or its `skill.json` is missing). The probe is a single `skill.json` read per skill — no API call, no full directory hash. Drift outranks every other status (`clean`/`modified`/`outdated`/`diverged`/`conflicts` for `status`; `up-to-date`/`outdated`/`conflicts` for `check`/`update`) because every other status compares against the lock as a baseline, and drift means the baseline itself is broken. Each result includes a structured `drift: { reason, lock_version, disk_version }` object in `--json` output (`reason` is one of `version_mismatch`, `missing_skill_json`, `missing_dir`). Wired into:
19
+ - `status` — the headline read-side fix; previously masked drift as `modified (local changes)`.
20
+ - `check` — previously silent (reported `up-to-date` because it never read disk).
21
+ - `list` — previously reported the lock's `version` as the installed version, lying when drift existed.
22
+ - `update` — pre-check now refuses to silently overwrite drift; surfaces it as `drift` in the table and skips affected skills with a remediation hint.
23
+ - `diff` — refuses with a `UsageError` when the target skill is drifted (the lock's `base_commit` is no longer a coherent comparison baseline; diffing against it produces noise, not signal).
24
+ - Add post-write verification to every command that mutates `skills-lock.json` and skill files together. After the lock is written, each command verifies that the new lock entry agrees with the on-disk `skill.json` and fails loudly (or warns, where appropriate) if they disagree. Closes the prevention half of the drift class — every write path now confirms lock+disk agreement before reporting success. Wired into:
25
+ - `install` (engine) — throws on mismatch with a `--fresh` remediation hint.
26
+ - `pull` — throws on the fast-forward path; warns on the three-way merge path (the merger can legitimately resolve `version` to either side, and the user already has conflict review work to do).
27
+ - `bump` — throws on mismatch; bump touches both files in sequence and is the most direct place where an interruption creates drift.
28
+ - `publish` — warns on mismatch; the push has already succeeded server-side, so a structural mismatch in the local lock should be surfaced but not block the success report.
29
+ - `convert` — throws on mismatch; convert generates both files together, so any disagreement is a fresh structural bug.
30
+ - New `cli/src/lock/verify.js` module exporting `verify_lock_disk_consistency(lock_entry, install_dir)` and `describe_drift(result)`. Single source of truth for the lock-vs-disk consistency probe; consumed by `status`, `check`, `list`, `diff`, `update`, `installer`, `pull`, `bump`, `publish`, and `convert`.
31
+ - New regression integration test file `cli/src/integration/drift.test.js` (10 tests, separate from `cli.test.js` to keep that file under the 750-line refactor threshold). Tests reconstruct the exact linwong scenario (lock 0.4.0, disk 0.3.0) and assert that `status`, `check`, `list`, and `diff` all surface drift via the structured `drift` field. Includes coverage for `missing_skill_json`, the "drift outranks modified" invariant, and the "drift overrides up-to-date" invariant. If any of these break, the bug class is back.
32
+
10
33
  ## [0.43.1] - 2026-05-11
11
34
 
12
35
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.43.1",
3
+ "version": "0.44.1",
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)",
@@ -8,8 +8,9 @@ const { find_project_root } = require('../config/paths')
8
8
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
9
9
  const { write_lock, update_lock_skills } = require('../lock/writer')
10
10
  const { hash_directory } = require('../lock/integrity')
11
+ const { verify_lock_disk_consistency } = require('../lock/verify')
11
12
  const repos_api = require('../api/repos')
12
- const { print_help, print_success, print_warn, print_json } = require('../ui/output')
13
+ const { print_help, print_success, print_warn, print_json, code } = require('../ui/output')
13
14
  const { exit_with_error, UsageError } = require('../utils/errors')
14
15
  const { EXIT_CODES } = require('../constants')
15
16
 
@@ -99,6 +100,18 @@ const run = (args) => catch_errors('Bump failed', async () => {
99
100
  if (!hash_err && integrity) updated_entry.integrity = integrity
100
101
  const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
101
102
  await write_lock(project_root, updated_skills)
103
+
104
+ // Post-write verification — bump touches both files, so confirm they
105
+ // agree before declaring success. An interrupted bump (e.g. process
106
+ // killed between write_manifest and write_lock above) is exactly the
107
+ // failure mode this guard exists to catch.
108
+ const [, verify_result] = await verify_lock_disk_consistency(updated_entry, dir)
109
+ if (verify_result && !verify_result.ok) {
110
+ throw new Error(
111
+ `Bump completed but lock and disk disagree (lock ${verify_result.expected}, disk ${verify_result.actual || 'none'}). ` +
112
+ `Re-run ${code(`happyskills install ${lock_key} --fresh`)} to restore.`
113
+ )
114
+ }
102
115
  }
103
116
 
104
117
  if (args.flags.json) {
@@ -1,10 +1,11 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { read_lock, get_all_locked_skills, get_via } = require('../lock/reader')
3
+ const { verify_lock_disk_consistency } = require('../lock/verify')
3
4
  const repos_api = require('../api/repos')
4
5
  const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
5
6
  const { green, yellow, red } = require('../ui/colors')
6
7
  const { exit_with_error } = require('../utils/errors')
7
- const { find_project_root } = require('../config/paths')
8
+ const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
8
9
  const { EXIT_CODES } = require('../constants')
9
10
 
10
11
  const HELP_TEXT = `Usage: happyskills check [owner/skill] [options]
@@ -59,17 +60,37 @@ const run = (args) => catch_errors('Check failed', async () => {
59
60
  const skill_names = to_check.map(([name]) => name)
60
61
  const [batch_err, batch_data] = await repos_api.check_updates(skill_names)
61
62
 
63
+ // Verify lock-vs-disk consistency for every skill before classifying. Drift
64
+ // must outrank up-to-date / outdated / conflicts because all those statuses
65
+ // trust the lock as a baseline — and drift means the baseline is broken.
66
+ const base_dir = skills_dir(false, project_root)
67
+ const drift_by_skill = {}
68
+ await Promise.all(to_check.map(async ([name, data]) => {
69
+ const short_name = name.split('/')[1] || name
70
+ const dir = skill_install_dir(base_dir, short_name)
71
+ const [, drift] = await verify_lock_disk_consistency(data, dir)
72
+ if (drift && !drift.ok) drift_by_skill[name] = drift
73
+ }))
74
+
62
75
  const results = []
63
76
  if (batch_err) {
64
77
  for (const [name, data] of to_check) {
65
- results.push({ skill: name, installed: data.version, latest: 'error', status: 'error', via: get_via(data) })
78
+ const drift = drift_by_skill[name]
79
+ if (drift) {
80
+ results.push({ skill: name, installed: data.version, latest: '-', status: 'drift', via: get_via(data), drift: { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } })
81
+ } else {
82
+ results.push({ skill: name, installed: data.version, latest: 'error', status: 'error', via: get_via(data) })
83
+ }
66
84
  }
67
85
  } else {
68
86
  for (const [name, data] of to_check) {
69
87
  const info = batch_data?.results?.[name]
70
88
  const via = get_via(data)
71
89
  const has_conflicts = (data.conflict_files || []).length > 0
72
- if (has_conflicts) {
90
+ const drift = drift_by_skill[name]
91
+ if (drift) {
92
+ results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'drift', via, drift: { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } })
93
+ } else if (has_conflicts) {
73
94
  results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'conflicts', via })
74
95
  } else if (info?.access_denied) {
75
96
  results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access', via })
@@ -90,7 +111,8 @@ const run = (args) => catch_errors('Check failed', async () => {
90
111
  const outdated_count = results.filter(r => r.status === 'outdated').length
91
112
  const up_to_date_count = results.filter(r => r.status === 'up-to-date').length
92
113
  const conflicts_count = results.filter(r => r.status === 'conflicts').length
93
- print_json({ data: { results, outdated_count, up_to_date_count, conflicts_count } })
114
+ const drift_count = results.filter(r => r.status === 'drift').length
115
+ print_json({ data: { results, outdated_count, up_to_date_count, conflicts_count, drift_count } })
94
116
  return
95
117
  }
96
118
 
@@ -98,6 +120,7 @@ const run = (args) => catch_errors('Check failed', async () => {
98
120
  'up-to-date': green,
99
121
  'outdated': yellow,
100
122
  'conflicts': red,
123
+ 'drift': red,
101
124
  'no-access': yellow,
102
125
  'error': red,
103
126
  'unknown': (s) => s
@@ -121,7 +144,17 @@ const run = (args) => catch_errors('Check failed', async () => {
121
144
 
122
145
  const outdated = results.filter(r => r.status === 'outdated')
123
146
  const conflicts = results.filter(r => r.status === 'conflicts')
147
+ const drifted = results.filter(r => r.status === 'drift')
124
148
  const no_access = results.filter(r => r.status === 'no-access')
149
+ if (drifted.length > 0) {
150
+ console.log()
151
+ print_warn(`${drifted.length} skill(s) drifted: lock and on-disk skill.json disagree.`)
152
+ for (const d of drifted) {
153
+ const disk = d.drift?.disk_version || 'none'
154
+ console.error(` - ${d.skill} (lock ${d.drift?.lock_version}, disk ${disk})`)
155
+ }
156
+ print_hint(`Run ${code('happyskills status')} for details, or ${code('happyskills install <skill> --fresh')} to restore.`)
157
+ }
125
158
  if (conflicts.length > 0) {
126
159
  console.log()
127
160
  print_warn(`${conflicts.length} skill(s) have unresolved merge conflicts.`)
@@ -130,7 +163,7 @@ const run = (args) => catch_errors('Check failed', async () => {
130
163
  if (outdated.length > 0) {
131
164
  console.log()
132
165
  print_info(`Run ${code('happyskills update --all')} to upgrade ${outdated.length} skill(s).`)
133
- } else if (conflicts.length === 0 && results.every(r => r.status === 'up-to-date')) {
166
+ } else if (conflicts.length === 0 && drifted.length === 0 && results.every(r => r.status === 'up-to-date')) {
134
167
  console.log()
135
168
  print_success('All skills are up to date.')
136
169
  }
@@ -11,6 +11,7 @@ const { write_manifest } = require('../manifest/writer')
11
11
  const { read_lock } = require('../lock/reader')
12
12
  const { write_lock, update_lock_skills } = require('../lock/writer')
13
13
  const { hash_directory } = require('../lock/integrity')
14
+ const { verify_lock_disk_consistency } = require('../lock/verify')
14
15
  const { skills_dir, find_project_root, lock_root } = require('../config/paths')
15
16
  const { file_exists } = require('../utils/fs')
16
17
  const { resolve_agents, link_to_agents } = require('../agents')
@@ -201,6 +202,17 @@ const run = (args) => catch_errors('Convert failed', async () => {
201
202
  const [lock_err] = await write_lock(lock_dir, new_skills)
202
203
  if (lock_err) { pub_spinner.fail('Failed to write lock file'); throw e('Lock write failed', lock_err) }
203
204
 
205
+ // Post-write verification — convert wrote both skill.json and the lock,
206
+ // so confirm they agree before declaring success.
207
+ const [, verify_result] = await verify_lock_disk_consistency(updates[full_name], skill_dir)
208
+ if (verify_result && !verify_result.ok) {
209
+ pub_spinner.fail('Convert verification failed')
210
+ throw new Error(
211
+ `Convert completed but lock and disk disagree (lock ${verify_result.expected}, disk ${verify_result.actual || 'none'}). ` +
212
+ `Re-run convert or fix skill.json manually before publishing.`
213
+ )
214
+ }
215
+
204
216
  // Link to detected agents (non-fatal — warnings only)
205
217
  const linked_agents = []
206
218
  const [agents_err, agents_result] = await resolve_agents(args.flags.agents || undefined)
@@ -7,6 +7,7 @@ const { build_report } = require('../merge/report')
7
7
  const { hash_blob } = require('../utils/git_hash')
8
8
  const { unified_diff } = require('../utils/text_diff')
9
9
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
10
+ const { verify_lock_disk_consistency } = require('../lock/verify')
10
11
  const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
11
12
  const { print_help, print_info, print_json, print_warn, code } = require('../ui/output')
12
13
  const { red, green, cyan, bold } = require('../ui/colors')
@@ -248,6 +249,23 @@ const run = (args) => catch_errors('Diff failed', async () => {
248
249
  const base_dir = skills_dir(is_global, project_root)
249
250
  const skill_dir = skill_install_dir(base_dir, repo)
250
251
 
252
+ // Drift (lock-vs-disk version mismatch) does NOT block diff — diff is the
253
+ // diagnostic tool a user reaches for precisely when things have diverged,
254
+ // and the obvious "remediation" (install --fresh) would destroy the local
255
+ // content they're trying to inspect. We probe drift only to warn the user
256
+ // that the "base" shown is the lock-recorded base (not the disk version's
257
+ // base). Skipped in --remote mode, which reads nothing from disk.
258
+ const drift = mode === 'remote'
259
+ ? null
260
+ : (await verify_lock_disk_consistency(lock_entry, skill_dir))[1]
261
+ const has_drift = drift && !drift.ok
262
+ if (has_drift && !args.flags.json) {
263
+ print_warn(
264
+ `${skill_name} has drift: lock ${drift.expected}, disk ${drift.actual || 'none'}. ` +
265
+ `Diff is shown against the lock-recorded base (${lock_entry.version}).`
266
+ )
267
+ }
268
+
251
269
  // Always need base files — keep full clone response for content diffs
252
270
  const [base_err, base_clone] = await repos_api.clone(owner, repo, null, { commit: lock_entry.base_commit })
253
271
  if (base_err) throw e('Failed to fetch base files', base_err)
@@ -266,7 +284,7 @@ const run = (args) => catch_errors('Diff failed', async () => {
266
284
  }
267
285
 
268
286
  if (args.flags.json) {
269
- print_json({ data: { mode, report } })
287
+ print_json({ data: { mode, report, drift: has_drift ? { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } : null } })
270
288
  } else {
271
289
  print_file_table(classified)
272
290
  print_report_diffs(report)
@@ -319,7 +337,7 @@ const run = (args) => catch_errors('Diff failed', async () => {
319
337
  }
320
338
 
321
339
  if (args.flags.json) {
322
- print_json({ data: { mode, report } })
340
+ print_json({ data: { mode, report, drift: has_drift ? { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } : null } })
323
341
  } else {
324
342
  print_file_table(classified)
325
343
  print_report_diffs(report)
@@ -1,13 +1,14 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
3
+ const { verify_lock_disk_consistency } = require('../lock/verify')
3
4
  const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
4
5
  const { file_exists, read_json } = require('../utils/fs')
5
6
  const { scan_skills_dir, scan_agent_orphan_skills } = require('../utils/skill_scanner')
6
7
  const { AGENTS } = require('../agents/registry')
7
8
  const { resolve_agents } = require('../agents/detector')
8
9
  const { get_skills_enabled_map } = require('../agents/status')
9
- const { print_help, print_table, print_json, print_info } = require('../ui/output')
10
- const { green, yellow } = require('../ui/colors')
10
+ const { print_help, print_table, print_json, print_info, print_warn, print_hint, code } = require('../ui/output')
11
+ const { green, yellow, red } = require('../ui/colors')
11
12
  const { exit_with_error } = require('../utils/errors')
12
13
  const { EXIT_CODES, SKILL_JSON, SKILL_TYPES } = require('../constants')
13
14
 
@@ -75,17 +76,30 @@ const run = (args) => catch_errors('List failed', async () => {
75
76
  return manifest?.type || SKILL_TYPES.SKILL
76
77
  }
77
78
 
79
+ // Compute drift up-front for every managed entry. Cheap (one skill.json read
80
+ // per skill) and lets both the JSON and human paths report it identically.
81
+ const drift_by_skill = {}
82
+ await Promise.all(managed_entries.map(async ([name, data]) => {
83
+ const short = name.split('/')[1]
84
+ const dir = skill_install_dir(base_dir, short)
85
+ const [, drift] = await verify_lock_disk_consistency(data, dir)
86
+ if (drift && !drift.ok) drift_by_skill[name] = drift
87
+ }))
88
+
78
89
  if (args.flags.json) {
79
90
  const skills_map = {}
80
91
  for (const [name, data] of managed_entries) {
81
92
  const short = name.split('/')[1]
82
93
  const dir = skill_install_dir(base_dir, short)
83
94
  const [, exists] = await file_exists(dir)
84
- const status = exists ? 'installed' : 'missing'
95
+ const drift = drift_by_skill[name]
96
+ const status = drift ? 'drift' : (exists ? 'installed' : 'missing')
85
97
  const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
86
98
  const type = await resolve_type(name, data)
87
99
  const enabled = enabled_map?.get(short) ?? true
88
- skills_map[name] = { version: data.version, type, source, status, enabled }
100
+ const entry = { version: data.version, type, source, status, enabled }
101
+ if (drift) entry.drift = { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual }
102
+ skills_map[name] = entry
89
103
  }
90
104
  const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
91
105
  const agent_orphan_list = orphan_skills.map(s => ({
@@ -102,13 +116,16 @@ const run = (args) => catch_errors('List failed', async () => {
102
116
  const short = name.split('/')[1]
103
117
  const dir = skill_install_dir(base_dir, short)
104
118
  const [, exists] = await file_exists(dir)
105
- const status = exists ? 'installed' : 'missing'
119
+ const drift = drift_by_skill[name]
120
+ const status_label = drift
121
+ ? red(`drift (disk ${drift.actual || 'none'})`)
122
+ : (exists ? 'installed' : yellow('missing'))
106
123
  const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
107
124
  const type = await resolve_type(name, data)
108
125
  const display_name = type === SKILL_TYPES.KIT ? `${name} [kit]` : name
109
126
  const enabled = enabled_map?.get(short) ?? true
110
127
  const enabled_label = enabled ? green('enabled') : yellow('disabled')
111
- rows.push([display_name, data.version, source, status, enabled_label])
128
+ rows.push([display_name, data.version, source, status_label, enabled_label])
112
129
  }
113
130
 
114
131
  for (const s of external_skills) {
@@ -121,6 +138,13 @@ const run = (args) => catch_errors('List failed', async () => {
121
138
  }
122
139
 
123
140
  print_table(['Skill', 'Version', 'Source', 'Status', 'Enabled'], rows)
141
+
142
+ const drift_count = Object.keys(drift_by_skill).length
143
+ if (drift_count > 0) {
144
+ console.log()
145
+ print_warn(`${drift_count} skill(s) drifted: lock and on-disk skill.json disagree.`)
146
+ print_hint(`Run ${code('happyskills status')} for details, or ${code('happyskills install <skill> --fresh')} to restore.`)
147
+ }
124
148
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
125
149
 
126
150
  module.exports = { run }
@@ -13,6 +13,7 @@ const { find_project_root } = require('../config/paths')
13
13
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
14
14
  const { write_lock, update_lock_skills } = require('../lock/writer')
15
15
  const { hash_directory } = require('../lock/integrity')
16
+ const { verify_lock_disk_consistency } = require('../lock/verify')
16
17
  const { validate_skill_md } = require('../validation/skill_md_rules')
17
18
  const { validate_skill_json } = require('../validation/skill_json_rules')
18
19
  const { validate_cross } = require('../validation/cross_rules')
@@ -261,6 +262,7 @@ const run = (args) => catch_errors('Publish failed', async () => {
261
262
 
262
263
  // Update lock file: set base_commit and base_integrity to new values
263
264
  const full_name = full_name_pre
265
+ let post_publish_entry = null
264
266
  if (!lock_err && lock_data) {
265
267
  const all_skills = get_all_locked_skills(lock_data)
266
268
  const suffix = `/${skill_name}`
@@ -281,6 +283,7 @@ const run = (args) => catch_errors('Publish failed', async () => {
281
283
  delete updated_entry.conflict_files
282
284
  const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
283
285
  await write_lock(project_root, updated_skills)
286
+ post_publish_entry = updated_entry
284
287
  } else {
285
288
  const new_entry = {
286
289
  version: manifest.version,
@@ -294,6 +297,21 @@ const run = (args) => catch_errors('Publish failed', async () => {
294
297
  }
295
298
  const updated_skills = update_lock_skills(lock_data, { [full_name]: new_entry })
296
299
  await write_lock(project_root, updated_skills)
300
+ post_publish_entry = new_entry
301
+ }
302
+ }
303
+
304
+ // Post-write verification — confirm the lock entry now agrees with the
305
+ // disk skill.json (the version we just published). The push has already
306
+ // succeeded server-side, so a mismatch here is a structural bug we want
307
+ // to surface immediately rather than silently bake into the lock.
308
+ if (post_publish_entry) {
309
+ const [, verify_result] = await verify_lock_disk_consistency(post_publish_entry, dir)
310
+ if (verify_result && !verify_result.ok) {
311
+ print_warn(
312
+ `Publish succeeded but lock and disk disagree (lock ${verify_result.expected}, disk ${verify_result.actual || 'none'}). ` +
313
+ `Run ${code(`happyskills install ${full_name} --fresh`)} to resync.`
314
+ )
297
315
  }
298
316
  }
299
317
 
@@ -14,6 +14,7 @@ const { satisfies } = require('../utils/semver')
14
14
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
15
15
  const { write_lock, update_lock_skills } = require('../lock/writer')
16
16
  const { hash_directory } = require('../lock/integrity')
17
+ const { verify_lock_disk_consistency } = require('../lock/verify')
17
18
  const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
18
19
  const { ensure_dir } = require('../utils/fs')
19
20
  const { create_spinner } = require('../ui/spinner')
@@ -211,6 +212,17 @@ const run = (args) => catch_errors('Pull failed', async () => {
211
212
  const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
212
213
  if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
213
214
 
215
+ // Post-write verification — see installer.js for the rationale.
216
+ const [, ff_verify] = await verify_lock_disk_consistency(updated_entry, skill_dir)
217
+ if (ff_verify && !ff_verify.ok) {
218
+ spinner.fail(`Pull verification failed: ${skill_name}`)
219
+ throw new Error(
220
+ `Pull of ${skill_name} completed but lock and disk disagree ` +
221
+ `(lock ${ff_verify.expected}, disk ${ff_verify.actual || 'none'}). ` +
222
+ `Re-run with happyskills install ${skill_name} --fresh to restore.`
223
+ )
224
+ }
225
+
214
226
  spinner.stop()
215
227
  if (args.flags.json) {
216
228
  print_json({ data: { status: 'fast_forward', skill: skill_name, version: cmp_data.head_version } })
@@ -451,11 +463,26 @@ const run = (args) => catch_errors('Pull failed', async () => {
451
463
  const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
452
464
  if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
453
465
 
466
+ // Post-merge verification — surface any disagreement between the new lock
467
+ // entry and the on-disk skill.json. A 3-way merge can legitimately resolve
468
+ // the version field to either side, so this is a warning, not a hard fail
469
+ // (the user already has conflict_files to review). For fast-forward see
470
+ // the matching block above which throws instead.
471
+ const [, merge_verify] = await verify_lock_disk_consistency(updated_entry, skill_dir)
472
+ const post_merge_drift = merge_verify && !merge_verify.ok ? merge_verify : null
473
+
454
474
  spinner.stop()
455
475
 
456
476
  if (args.flags.json) {
457
477
  const status = conflict_files.length > 0 ? 'conflicts' : 'merged'
458
478
  const output = { status, report, conflict_files, json_conflicts }
479
+ if (post_merge_drift) {
480
+ output.drift = {
481
+ reason: post_merge_drift.reason,
482
+ lock_version: post_merge_drift.expected,
483
+ disk_version: post_merge_drift.actual
484
+ }
485
+ }
459
486
  if (full_report) output.resolution_steps = build_resolution_steps(report, json_conflicts)
460
487
  print_json({ data: output })
461
488
  } else {
@@ -484,6 +511,10 @@ const run = (args) => catch_errors('Pull failed', async () => {
484
511
  console.error(` - ${f}`)
485
512
  }
486
513
  }
514
+ if (post_merge_drift) {
515
+ print_warn(`Lock and on-disk skill.json disagree after merge: lock ${post_merge_drift.expected}, disk ${post_merge_drift.actual || 'none'}.`)
516
+ print_hint(`Update skill.json's version to match the lock, or run ${code(`happyskills install ${skill_name} --fresh`)} to re-sync.`)
517
+ }
487
518
  }
488
519
 
489
520
  // 7. Dependency reconciliation
@@ -1,8 +1,9 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
3
3
  const { detect_status } = require('../merge/detector')
4
+ const { verify_lock_disk_consistency, describe_drift } = require('../lock/verify')
4
5
  const repos_api = require('../api/repos')
5
- const { print_help, print_info, print_json } = require('../ui/output')
6
+ const { print_help, print_info, print_json, print_warn, print_hint, code } = require('../ui/output')
6
7
  const { exit_with_error, UsageError } = require('../utils/errors')
7
8
  const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
8
9
  const { EXIT_CODES } = require('../constants')
@@ -25,7 +26,11 @@ Examples:
25
26
  happyskills st acme/deploy-aws
26
27
  happyskills status --json`
27
28
 
28
- const classify = (local_modified, remote_updated, has_conflicts) => {
29
+ // Drift outranks every other status: when the lock and the on-disk skill.json
30
+ // disagree about what's installed, every other comparison (modified/outdated/
31
+ // diverged/conflicts) is computed against an untrustworthy baseline.
32
+ const classify = (drift, local_modified, remote_updated, has_conflicts) => {
33
+ if (drift && !drift.ok) return 'drift'
29
34
  if (has_conflicts) return 'conflicts'
30
35
  if (local_modified && remote_updated) return 'diverged'
31
36
  if (local_modified) return 'modified'
@@ -69,15 +74,20 @@ const run = (args) => catch_errors('Status failed', async () => {
69
74
 
70
75
  const base_dir = skills_dir(is_global, project_root)
71
76
 
72
- // Detect local modifications for all skills in parallel
73
- const detections = await Promise.all(entries.map(([name, data]) => {
74
- if (!data) return { name, data, det: null }
77
+ // Detect drift (lock-vs-disk version mismatch) and local modifications in parallel.
78
+ // The drift probe is cheap (one skill.json read + version compare); detect_status
79
+ // is the heavier integrity check. Running both lets us distinguish "user edited
80
+ // content" from "structural baseline broken".
81
+ const detections = await Promise.all(entries.map(async ([name, data]) => {
82
+ if (!data) return { name, data, det: null, drift: null }
75
83
  const short_name = name.split('/')[1] || name
76
84
  const dir = skill_install_dir(base_dir, short_name)
77
- return detect_status(data, dir, { skill_name: name, project_root, is_global }).then(([, det]) => ({ name, data, det }))
85
+ const [, drift] = await verify_lock_disk_consistency(data, dir)
86
+ const [, det] = await detect_status(data, dir, { skill_name: name, project_root, is_global })
87
+ return { name, data, det, drift }
78
88
  }))
79
89
 
80
- const results = detections.map(({ name, data, det }) => {
90
+ const results = detections.map(({ name, data, det, drift }) => {
81
91
  if (!data) return { skill: name, status: 'not_found', local_modified: false, remote_updated: false }
82
92
  const has_conflicts = (data.conflict_files || []).length > 0
83
93
  return {
@@ -91,7 +101,10 @@ const run = (args) => catch_errors('Status failed', async () => {
91
101
  remote_version: null,
92
102
  remote_commit: null,
93
103
  conflict_files: data.conflict_files || [],
94
- status: has_conflicts ? 'conflicts' : 'clean'
104
+ drift: drift && !drift.ok
105
+ ? { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual }
106
+ : null,
107
+ status: drift && !drift.ok ? 'drift' : (has_conflicts ? 'conflicts' : 'clean')
95
108
  }
96
109
  })
97
110
 
@@ -113,10 +126,12 @@ const run = (args) => catch_errors('Status failed', async () => {
113
126
  }
114
127
  }
115
128
 
116
- // Classify each result
129
+ // Classify each result. The drift field is the canonical structural-baseline
130
+ // signal; pass it to classify so it can outrank everything else.
117
131
  for (const r of results) {
118
132
  if (r.status !== 'not_found') {
119
- r.status = classify(r.local_modified, r.remote_updated, r.conflict_files.length > 0)
133
+ const drift_check = r.drift ? { ok: false } : { ok: true }
134
+ r.status = classify(drift_check, r.local_modified, r.remote_updated, r.conflict_files.length > 0)
120
135
  }
121
136
  }
122
137
 
@@ -135,12 +150,18 @@ const run = (args) => catch_errors('Status failed', async () => {
135
150
  skill: r.skill,
136
151
  base: r.base_version || '?',
137
152
  remote: r.remote_version || '?',
138
- status: r.status === 'conflicts' ? 'conflicts (unresolved merge conflicts)'
139
- : r.status === 'diverged' ? 'diverged (local + remote changes)'
140
- : r.status === 'modified' ? 'modified (local changes)'
141
- : r.status === 'outdated' ? 'outdated (remote changes)'
142
- : r.status === 'not_found' ? 'not found'
143
- : 'clean'
153
+ status: r.status === 'drift' ? describe_drift({
154
+ ok: false,
155
+ reason: r.drift?.reason,
156
+ expected: r.drift?.lock_version,
157
+ actual: r.drift?.disk_version
158
+ })
159
+ : r.status === 'conflicts' ? 'conflicts (unresolved merge conflicts)'
160
+ : r.status === 'diverged' ? 'diverged (local + remote changes)'
161
+ : r.status === 'modified' ? 'modified (local changes)'
162
+ : r.status === 'outdated' ? 'outdated (remote changes)'
163
+ : r.status === 'not_found' ? 'not found'
164
+ : 'clean'
144
165
  }))
145
166
 
146
167
  const w_skill = Math.max(col_skill.length, ...rows.map(r => r.skill.length))
@@ -153,6 +174,14 @@ const run = (args) => catch_errors('Status failed', async () => {
153
174
  for (const r of rows) {
154
175
  console.log(`${pad(r.skill, w_skill)} ${pad(r.base, w_base)} ${pad(r.remote, w_remote)} ${r.status}`)
155
176
  }
177
+
178
+ const drifted = results.filter(r => r.status === 'drift')
179
+ if (drifted.length > 0) {
180
+ console.log()
181
+ print_warn(`${drifted.length} skill(s) drifted: lock and on-disk skill.json disagree.`)
182
+ print_hint(`Restore install record: ${code('happyskills install <skill> --fresh')}`)
183
+ print_hint(`Or accept the on-disk version: bump the lock by reinstalling at the disk version.`)
184
+ }
156
185
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
157
186
 
158
187
  module.exports = { run }
@@ -3,6 +3,7 @@ const { install } = require('../engine/installer')
3
3
  const { read_lock, get_all_locked_skills, get_via } = require('../lock/reader')
4
4
  const { read_manifest } = require('../manifest/reader')
5
5
  const { detect_status } = require('../merge/detector')
6
+ const { verify_lock_disk_consistency } = require('../lock/verify')
6
7
  const repos_api = require('../api/repos')
7
8
  const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
8
9
  const { green, yellow, red } = require('../ui/colors')
@@ -140,18 +141,27 @@ const run = (args) => catch_errors('Update failed', async () => {
140
141
  } else {
141
142
  spinner?.succeed('Checked for updates')
142
143
 
143
- // Read installed manifests in parallel for dependency-drift detection
144
+ // Read installed manifests + drift in parallel. Drift detection here
145
+ // matches the read-side surfacing in status/check — update should not
146
+ // silently treat a drifted skill as "up-to-date" or auto-overwrite it.
144
147
  const manifest_map = {}
145
- await Promise.all(candidates.map(async ([name]) => {
148
+ const drift_by_skill = {}
149
+ await Promise.all(candidates.map(async ([name, data]) => {
146
150
  const short_name = name.split('/')[1] || name
147
151
  const dir = skill_install_dir(base_dir, short_name)
148
152
  const [, manifest] = await read_manifest(dir)
149
153
  if (manifest) manifest_map[name] = manifest
154
+ const [, drift] = await verify_lock_disk_consistency(data, dir)
155
+ if (drift && !drift.ok) drift_by_skill[name] = drift
150
156
  }))
151
157
 
152
158
  for (const [name, data] of candidates) {
153
159
  const info = batch_data?.results?.[name]
154
- if (info?.access_denied) {
160
+ const drift = drift_by_skill[name]
161
+ if (drift) {
162
+ // Drift outranks every other status — fix the baseline before updating.
163
+ results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'drift', drift: { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } })
164
+ } else if (info?.access_denied) {
155
165
  results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
156
166
  } else if (!info || !info.latest_version) {
157
167
  results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
@@ -171,6 +181,7 @@ const run = (args) => catch_errors('Update failed', async () => {
171
181
 
172
182
  const outdated = results.filter(r => r.status === 'outdated')
173
183
  const up_to_date = results.filter(r => r.status === 'up-to-date')
184
+ const drifted = results.filter(r => r.status === 'drift')
174
185
 
175
186
  // Verify and repair symlinks (even when nothing is outdated)
176
187
  const [, agents_data] = await resolve_agents(args.flags.agents)
@@ -202,14 +213,20 @@ const run = (args) => catch_errors('Update failed', async () => {
202
213
  // If nothing to update, report and exit
203
214
  if (outdated.length === 0) {
204
215
  if (args.flags.json) {
205
- print_json({ data: { results, outdated_count: 0, up_to_date_count: up_to_date.length, updated: [], already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })), symlink_repairs, errors: [] } })
216
+ print_json({ data: { results, outdated_count: 0, up_to_date_count: up_to_date.length, drift_count: drifted.length, updated: [], already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })), symlink_repairs, errors: [] } })
206
217
  return
207
218
  }
208
219
  print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
209
- r.skill, r.installed, r.latest, green(r.status)
220
+ r.skill, r.installed, r.latest, r.status === 'drift' ? red('drift') : green(r.status)
210
221
  ]))
211
222
  console.log()
212
- if (symlink_repairs.length > 0) {
223
+ if (drifted.length > 0) {
224
+ print_warn(`${drifted.length} skill(s) drifted: lock and on-disk skill.json disagree.`)
225
+ for (const r of drifted) {
226
+ console.error(` - ${r.skill} (lock ${r.drift?.lock_version}, disk ${r.drift?.disk_version || 'none'})`)
227
+ }
228
+ print_hint(`Restore each one with ${code('happyskills install <skill> --fresh')} before updating.`)
229
+ } else if (symlink_repairs.length > 0) {
213
230
  print_success(`All skills are up to date. Repaired ${symlink_repairs.length} symlink(s).`)
214
231
  } else {
215
232
  print_success('All skills are up to date.')
@@ -219,11 +236,18 @@ const run = (args) => catch_errors('Update failed', async () => {
219
236
 
220
237
  // Show results table (non-json)
221
238
  if (!args.flags.json) {
222
- const status_colors = { 'up-to-date': green, 'outdated': yellow, 'no-access': yellow, 'error': red, 'unknown': (s) => s }
239
+ const status_colors = { 'up-to-date': green, 'outdated': yellow, 'drift': red, 'no-access': yellow, 'error': red, 'unknown': (s) => s }
223
240
  print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
224
241
  r.skill, r.installed, r.latest, (status_colors[r.status] || ((s) => s))(r.status)
225
242
  ]))
226
243
  console.log()
244
+ if (drifted.length > 0) {
245
+ print_warn(`${drifted.length} skill(s) drifted — these will be skipped:`)
246
+ for (const r of drifted) {
247
+ console.error(` - ${r.skill} (lock ${r.drift?.lock_version}, disk ${r.drift?.disk_version || 'none'})`)
248
+ }
249
+ print_hint(`Restore each one with ${code('happyskills install <skill> --fresh')} to update them.`)
250
+ }
227
251
  print_info(`${outdated.length} skill(s) can be updated.`)
228
252
  }
229
253
 
@@ -281,6 +305,7 @@ const run = (args) => catch_errors('Update failed', async () => {
281
305
  results,
282
306
  outdated_count: outdated.length,
283
307
  up_to_date_count: up_to_date.length,
308
+ drift_count: drifted.length,
284
309
  updated,
285
310
  skipped,
286
311
  already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
@@ -8,6 +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
12
  const { skills_dir, tmp_dir, skill_install_dir, lock_root } = require('../config/paths')
12
13
  const { ensure_dir, remove_dir, file_exists, read_json } = require('../utils/fs')
13
14
  const { SKILL_JSON, SKILL_TYPES } = require('../constants')
@@ -256,6 +257,24 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
256
257
  const [lock_errors] = await write_lock(lock_dir, new_skills)
257
258
  if (lock_errors) { spinner.fail('Failed to write lock file'); throw e('Lock write failed', lock_errors) }
258
259
 
260
+ // Post-write verification: confirm every newly-written lock entry agrees
261
+ // with the on-disk skill.json. Catches any drift the install pipeline
262
+ // would have created (interrupted rename, resolver-vs-disk version skew,
263
+ // extractor regression, etc.) before declaring success.
264
+ for (const { pkg } of downloaded) {
265
+ const name = pkg.skill.split('/')[1]
266
+ const final_dir = skill_install_dir(base_dir, name)
267
+ const [, verify_result] = await verify_lock_disk_consistency(updates[pkg.skill], final_dir)
268
+ if (verify_result && !verify_result.ok) {
269
+ spinner.fail(`Install verification failed: ${pkg.skill}`)
270
+ throw new Error(
271
+ `Install of ${pkg.skill} completed but lock and disk disagree ` +
272
+ `(lock ${verify_result.expected}, disk ${verify_result.actual || 'none'}). ` +
273
+ `Re-run with --fresh to retry.`
274
+ )
275
+ }
276
+ }
277
+
259
278
  spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
260
279
 
261
280
  const linked_count = downloaded.length - disabled_skills.size
@@ -0,0 +1,300 @@
1
+ 'use strict'
2
+ /**
3
+ * Regression tests for the lock-vs-disk drift bug class.
4
+ *
5
+ * Background: an interrupted `update`/`install`, a manual edit to skill.json,
6
+ * or a partial refresh can leave the lock entry's `version` field disagreeing
7
+ * with the on-disk skill.json's `version`. Before this fix:
8
+ * - `check` reported `up-to-date` (it never read the disk)
9
+ * - `status` reported `modified (local changes)` (it noticed the integrity
10
+ * hash differed but mislabeled the cause as user edits)
11
+ * - `list` reported the lock's version as the installed version (lying)
12
+ * - `diff` used the lock's `base_commit` as a comparison baseline that no
13
+ * longer matched what was on disk (incoherent diff)
14
+ *
15
+ * Note on `diff`: an earlier fix hard-blocked `diff` on drift. That was wrong
16
+ * — diff is the diagnostic tool a user reaches for *because* of drift, and
17
+ * the suggested "install --fresh" remediation would destroy the local
18
+ * content. The current behavior is to warn and proceed.
19
+ * - `update` could decide a drifted skill was up-to-date and skip it
20
+ *
21
+ * These tests reconstruct the exact linwong scenario that surfaced the bug
22
+ * (lock 0.4.0, disk 0.3.0) and assert every drift-aware command surfaces it
23
+ * via the structured `drift` field. If any of these break, the bug class is
24
+ * back.
25
+ */
26
+ const { describe, it } = require('node:test')
27
+ const assert = require('node:assert/strict')
28
+ const { spawnSync } = require('child_process')
29
+ const fs = require('fs')
30
+ const os = require('os')
31
+ const path = require('path')
32
+
33
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
34
+ const NODE = process.execPath
35
+
36
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'happyskills-drift-test-'))
37
+
38
+ const run = (args, opts) => {
39
+ const result = spawnSync(NODE, [CLI, ...args], {
40
+ env: {
41
+ ...process.env,
42
+ NO_COLOR: '1',
43
+ HAPPYSKILLS_API_URL: 'http://localhost:0',
44
+ ...(opts?.env || {})
45
+ },
46
+ encoding: 'utf-8',
47
+ timeout: 10000,
48
+ cwd: opts?.cwd
49
+ })
50
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
51
+ }
52
+
53
+ const parse_json = (stdout, label) => {
54
+ try { return JSON.parse(stdout) }
55
+ catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
56
+ }
57
+
58
+ /**
59
+ * Scaffold a project with an explicitly drifted skill: lock entry says one
60
+ * version, on-disk skill.json says another. Mirrors the linwong bug exactly.
61
+ *
62
+ * @param {Array<{full, short, lock_version, disk_version, integrity?}>} skills
63
+ */
64
+ const scaffold_drifted = (skills) => {
65
+ const root = make_tmp()
66
+ const lock_skills = {}
67
+
68
+ for (const s of skills) {
69
+ const skill_dir = path.join(root, '.agents', 'skills', s.short)
70
+ fs.mkdirSync(skill_dir, { recursive: true })
71
+ fs.writeFileSync(
72
+ path.join(skill_dir, 'SKILL.md'),
73
+ `---\nname: ${s.short}\ndescription: test skill for drift regression\n---\nTest body\n`
74
+ )
75
+ // disk_version is what skill.json claims — may differ from lock
76
+ fs.writeFileSync(
77
+ path.join(skill_dir, 'skill.json'),
78
+ JSON.stringify({
79
+ name: s.short,
80
+ version: s.disk_version,
81
+ type: 'skill',
82
+ description: 'test skill for drift regression'
83
+ }, null, '\t')
84
+ )
85
+
86
+ lock_skills[s.full] = {
87
+ version: s.lock_version,
88
+ type: 'skill',
89
+ ref: `refs/tags/v${s.lock_version}`,
90
+ commit: 'lockcommit',
91
+ integrity: s.integrity || null,
92
+ base_commit: 'lockcommit',
93
+ base_integrity: s.integrity || null,
94
+ requested_by: ['__root__'],
95
+ dependencies: {}
96
+ }
97
+ }
98
+
99
+ fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
100
+ lockVersion: 2,
101
+ generatedAt: new Date().toISOString(),
102
+ skills: lock_skills
103
+ }, null, '\t'))
104
+
105
+ return { root, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
106
+ }
107
+
108
+ // ─── status ───────────────────────────────────────────────────────────────────
109
+
110
+ describe('status — drift detection', () => {
111
+ it('reports drift when lock version differs from disk skill.json version (the linwong bug)', () => {
112
+ const { root, cleanup } = scaffold_drifted([
113
+ { full: 'happyskillsai/happyskills-design', short: 'happyskills-design', lock_version: '0.4.0', disk_version: '0.3.0' }
114
+ ])
115
+ try {
116
+ const { code, stdout } = run(['status', '--json'], { cwd: root })
117
+ assert.strictEqual(code, 0, 'status should exit 0')
118
+ const out = parse_json(stdout, 'status --json')
119
+ const result = out.data.results.find(r => r.skill === 'happyskillsai/happyskills-design')
120
+ assert.ok(result, 'result for the drifted skill must be present')
121
+ assert.strictEqual(result.status, 'drift', 'status must be reported as drift, not modified or clean')
122
+ assert.deepEqual(result.drift, {
123
+ reason: 'version_mismatch',
124
+ lock_version: '0.4.0',
125
+ disk_version: '0.3.0'
126
+ })
127
+ } finally { cleanup() }
128
+ })
129
+
130
+ it('drift outranks modified — does not mislabel structural drift as local edits', () => {
131
+ // Critical regression: previously reported as "modified (local changes)"
132
+ const { root, cleanup } = scaffold_drifted([
133
+ { full: 'acme/foo', short: 'foo', lock_version: '2.0.0', disk_version: '1.0.0' }
134
+ ])
135
+ try {
136
+ const { code, stdout } = run(['status', '--json'], { cwd: root })
137
+ assert.strictEqual(code, 0)
138
+ const out = parse_json(stdout, 'status --json')
139
+ const result = out.data.results[0]
140
+ assert.strictEqual(result.status, 'drift', 'must be drift, not modified')
141
+ assert.notStrictEqual(result.status, 'modified', 'modified would mislead — this is structural drift, not user edits')
142
+ } finally { cleanup() }
143
+ })
144
+
145
+ it('reports clean when lock and disk agree', () => {
146
+ const { root, cleanup } = scaffold_drifted([
147
+ { full: 'acme/agree', short: 'agree', lock_version: '1.0.0', disk_version: '1.0.0' }
148
+ ])
149
+ try {
150
+ const { code, stdout } = run(['status', '--json'], { cwd: root })
151
+ assert.strictEqual(code, 0)
152
+ const out = parse_json(stdout, 'status --json')
153
+ const result = out.data.results[0]
154
+ assert.strictEqual(result.drift, null)
155
+ assert.notStrictEqual(result.status, 'drift')
156
+ } finally { cleanup() }
157
+ })
158
+
159
+ it('detects missing_skill_json drift when skill.json is gone', () => {
160
+ const { root, cleanup } = scaffold_drifted([
161
+ { full: 'acme/headless', short: 'headless', lock_version: '1.0.0', disk_version: '1.0.0' }
162
+ ])
163
+ try {
164
+ fs.unlinkSync(path.join(root, '.agents', 'skills', 'headless', 'skill.json'))
165
+ const { code, stdout } = run(['status', '--json'], { cwd: root })
166
+ assert.strictEqual(code, 0)
167
+ const out = parse_json(stdout, 'status --json')
168
+ const result = out.data.results[0]
169
+ assert.strictEqual(result.status, 'drift')
170
+ assert.strictEqual(result.drift.reason, 'missing_skill_json')
171
+ } finally { cleanup() }
172
+ })
173
+
174
+ it('human output includes the drift cell with both versions', () => {
175
+ const { root, cleanup } = scaffold_drifted([
176
+ { full: 'acme/show', short: 'show', lock_version: '0.4.0', disk_version: '0.3.0' }
177
+ ])
178
+ try {
179
+ const { code, stdout } = run(['status'], { cwd: root })
180
+ assert.strictEqual(code, 0)
181
+ assert.ok(stdout.includes('drift'), 'status table must contain "drift"')
182
+ assert.ok(stdout.includes('0.4.0'), 'must show lock version')
183
+ assert.ok(stdout.includes('0.3.0'), 'must show disk version')
184
+ } finally { cleanup() }
185
+ })
186
+ })
187
+
188
+ // ─── check ────────────────────────────────────────────────────────────────────
189
+
190
+ describe('check — drift detection', () => {
191
+ it('reports drift even when the registry API is unreachable', () => {
192
+ // HAPPYSKILLS_API_URL=http://localhost:0 makes the API call fail.
193
+ // Drift detection is purely local — it must still surface.
194
+ const { root, cleanup } = scaffold_drifted([
195
+ { full: 'acme/local-only', short: 'local-only', lock_version: '0.4.0', disk_version: '0.3.0' }
196
+ ])
197
+ try {
198
+ const { code, stdout } = run(['check', '--json'], { cwd: root })
199
+ assert.strictEqual(code, 0)
200
+ const out = parse_json(stdout, 'check --json')
201
+ const result = out.data.results.find(r => r.skill === 'acme/local-only')
202
+ assert.ok(result, 'result must be present even with API down')
203
+ assert.strictEqual(result.status, 'drift')
204
+ assert.deepEqual(result.drift, {
205
+ reason: 'version_mismatch',
206
+ lock_version: '0.4.0',
207
+ disk_version: '0.3.0'
208
+ })
209
+ } finally { cleanup() }
210
+ })
211
+
212
+ it('drift_count appears in the JSON summary', () => {
213
+ const { root, cleanup } = scaffold_drifted([
214
+ { full: 'acme/one', short: 'one', lock_version: '1.0.0', disk_version: '0.9.0' },
215
+ { full: 'acme/two', short: 'two', lock_version: '2.0.0', disk_version: '1.0.0' }
216
+ ])
217
+ try {
218
+ const { code, stdout } = run(['check', '--json'], { cwd: root })
219
+ assert.strictEqual(code, 0)
220
+ const out = parse_json(stdout, 'check --json')
221
+ assert.strictEqual(out.data.drift_count, 2)
222
+ } finally { cleanup() }
223
+ })
224
+
225
+ it('drift overrides up-to-date — drift cannot be silently masked', () => {
226
+ const { root, cleanup } = scaffold_drifted([
227
+ { full: 'acme/silent', short: 'silent', lock_version: '0.4.0', disk_version: '0.3.0' }
228
+ ])
229
+ try {
230
+ const { code, stdout } = run(['check', '--json'], { cwd: root })
231
+ assert.strictEqual(code, 0)
232
+ const out = parse_json(stdout, 'check --json')
233
+ const result = out.data.results[0]
234
+ assert.notStrictEqual(result.status, 'up-to-date', 'must not silently report up-to-date when drifted')
235
+ assert.strictEqual(result.status, 'drift')
236
+ } finally { cleanup() }
237
+ })
238
+ })
239
+
240
+ // ─── list ─────────────────────────────────────────────────────────────────────
241
+
242
+ describe('list — drift surfacing', () => {
243
+ it('list --json marks drifted skills with status "drift" and a drift object', () => {
244
+ const { root, cleanup } = scaffold_drifted([
245
+ { full: 'acme/drifted', short: 'drifted', lock_version: '0.4.0', disk_version: '0.3.0' },
246
+ { full: 'acme/clean', short: 'clean', lock_version: '1.0.0', disk_version: '1.0.0' }
247
+ ])
248
+ try {
249
+ const { code, stdout } = run(['list', '--json'], { cwd: root })
250
+ assert.strictEqual(code, 0)
251
+ const out = parse_json(stdout, 'list --json')
252
+ assert.strictEqual(out.data.skills['acme/drifted'].status, 'drift')
253
+ assert.deepEqual(out.data.skills['acme/drifted'].drift, {
254
+ reason: 'version_mismatch',
255
+ lock_version: '0.4.0',
256
+ disk_version: '0.3.0'
257
+ })
258
+ assert.strictEqual(out.data.skills['acme/clean'].status, 'installed')
259
+ assert.strictEqual(out.data.skills['acme/clean'].drift, undefined)
260
+ } finally { cleanup() }
261
+ })
262
+ })
263
+
264
+ // ─── diff ─────────────────────────────────────────────────────────────────────
265
+
266
+ describe('diff — drift does not block', () => {
267
+ it('diff proceeds past the drift check (no UsageError) and warns the user', () => {
268
+ // Drift must not block diff: diff is the diagnostic tool for this case,
269
+ // and "install --fresh" would destroy the local content. We assert that
270
+ // the command moves past the drift gate — it will then fail trying to
271
+ // reach the fake API URL, but exit code 2 (UsageError) means the old
272
+ // pre-block is back.
273
+ const { root, cleanup } = scaffold_drifted([
274
+ { full: 'acme/cant-diff', short: 'cant-diff', lock_version: '0.4.0', disk_version: '0.3.0' }
275
+ ])
276
+ try {
277
+ const { code, stderr } = run(['diff', 'acme/cant-diff'], { cwd: root })
278
+ assert.notStrictEqual(code, 2, 'must not exit with the old drift UsageError')
279
+ assert.ok(
280
+ stderr.toLowerCase().includes('drift') && stderr.includes('0.4.0') && stderr.includes('0.3.0'),
281
+ 'should warn about drift with both versions'
282
+ )
283
+ assert.ok(
284
+ !/before diffing/i.test(stderr),
285
+ 'must not tell the user to fix drift before diffing'
286
+ )
287
+ } finally { cleanup() }
288
+ })
289
+
290
+ it('diff --remote skips the drift probe entirely (drift is irrelevant when no disk is read)', () => {
291
+ const { root, cleanup } = scaffold_drifted([
292
+ { full: 'acme/cant-diff', short: 'cant-diff', lock_version: '0.4.0', disk_version: '0.3.0' }
293
+ ])
294
+ try {
295
+ const { code, stderr } = run(['diff', 'acme/cant-diff', '--remote'], { cwd: root })
296
+ assert.notStrictEqual(code, 2, 'must not exit with the old drift UsageError')
297
+ assert.ok(!/drift/i.test(stderr), '--remote mode should not surface drift at all')
298
+ } finally { cleanup() }
299
+ })
300
+ })
@@ -0,0 +1,48 @@
1
+ const path = require('path')
2
+ const { error: { catch_errors } } = require('puffy-core')
3
+ const { read_json, file_exists } = require('../utils/fs')
4
+ const { SKILL_JSON } = require('../constants')
5
+
6
+ // Cheapest possible drift probe: read the on-disk skill.json and compare its
7
+ // version field to the lock entry's version. Catches the most common class of
8
+ // lock/disk drift (interrupted install, manual file edit, partial update)
9
+ // without paying for a full directory hash.
10
+ //
11
+ // Result shapes:
12
+ // { ok: true }
13
+ // { ok: false, reason: 'missing_dir', expected, actual: null }
14
+ // { ok: false, reason: 'missing_skill_json', expected, actual: null }
15
+ // { ok: false, reason: 'version_mismatch', expected, actual }
16
+ const verify_lock_disk_consistency = (lock_entry, install_dir) => catch_errors('Failed to verify lock/disk consistency', async () => {
17
+ const expected = lock_entry?.version || null
18
+
19
+ // Nothing in the lock to check against — caller has nothing to verify.
20
+ if (!expected) return { ok: true }
21
+
22
+ const [, dir_exists] = await file_exists(install_dir)
23
+ if (!dir_exists) return { ok: false, reason: 'missing_dir', expected, actual: null }
24
+
25
+ const manifest_path = path.join(install_dir, SKILL_JSON)
26
+ const [, manifest_exists] = await file_exists(manifest_path)
27
+ if (!manifest_exists) return { ok: false, reason: 'missing_skill_json', expected, actual: null }
28
+
29
+ const [read_err, manifest] = await read_json(manifest_path)
30
+ if (read_err) return { ok: false, reason: 'missing_skill_json', expected, actual: null }
31
+
32
+ const actual = manifest?.version || null
33
+ if (actual !== expected) return { ok: false, reason: 'version_mismatch', expected, actual }
34
+
35
+ return { ok: true }
36
+ })
37
+
38
+ // Plain-language description of a drift result, suitable for the principal-facing
39
+ // table cell (status column). Returns null when ok.
40
+ const describe_drift = (verify_result) => {
41
+ if (!verify_result || verify_result.ok) return null
42
+ if (verify_result.reason === 'missing_dir') return `drift (lock ${verify_result.expected}, skill not on disk)`
43
+ if (verify_result.reason === 'missing_skill_json') return `drift (lock ${verify_result.expected}, no skill.json on disk)`
44
+ if (verify_result.reason === 'version_mismatch') return `drift (lock ${verify_result.expected}, disk ${verify_result.actual})`
45
+ return 'drift'
46
+ }
47
+
48
+ module.exports = { verify_lock_disk_consistency, describe_drift }
@@ -0,0 +1,137 @@
1
+ 'use strict'
2
+ const { describe, it } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const os = require('os')
5
+ const path = require('path')
6
+ const fs = require('fs')
7
+
8
+ const { verify_lock_disk_consistency, describe_drift } = require('./verify')
9
+
10
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-verify-test-'))
11
+ const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
12
+
13
+ const write_skill_json = (dir, manifest) => {
14
+ fs.mkdirSync(dir, { recursive: true })
15
+ fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest, null, '\t'))
16
+ }
17
+
18
+ describe('verify_lock_disk_consistency', () => {
19
+ it('returns ok when lock version matches disk version', async () => {
20
+ const dir = make_tmp()
21
+ try {
22
+ const install_dir = path.join(dir, 'my-skill')
23
+ write_skill_json(install_dir, { name: 'my-skill', version: '1.2.0' })
24
+ const [err, result] = await verify_lock_disk_consistency({ version: '1.2.0' }, install_dir)
25
+ assert.strictEqual(err, null)
26
+ assert.deepEqual(result, { ok: true })
27
+ } finally { cleanup(dir) }
28
+ })
29
+
30
+ it('detects version_mismatch when disk skill.json has a different version (the linwong bug)', async () => {
31
+ const dir = make_tmp()
32
+ try {
33
+ const install_dir = path.join(dir, 'happyskills-design')
34
+ write_skill_json(install_dir, { name: 'happyskills-design', version: '0.3.0' })
35
+ const [err, result] = await verify_lock_disk_consistency({ version: '0.4.0' }, install_dir)
36
+ assert.strictEqual(err, null)
37
+ assert.deepEqual(result, { ok: false, reason: 'version_mismatch', expected: '0.4.0', actual: '0.3.0' })
38
+ } finally { cleanup(dir) }
39
+ })
40
+
41
+ it('detects missing_dir when the install directory does not exist', async () => {
42
+ const dir = make_tmp()
43
+ try {
44
+ const install_dir = path.join(dir, 'never-installed')
45
+ const [err, result] = await verify_lock_disk_consistency({ version: '1.0.0' }, install_dir)
46
+ assert.strictEqual(err, null)
47
+ assert.deepEqual(result, { ok: false, reason: 'missing_dir', expected: '1.0.0', actual: null })
48
+ } finally { cleanup(dir) }
49
+ })
50
+
51
+ it('detects missing_skill_json when the directory exists but skill.json is gone', async () => {
52
+ const dir = make_tmp()
53
+ try {
54
+ const install_dir = path.join(dir, 'broken-skill')
55
+ fs.mkdirSync(install_dir)
56
+ fs.writeFileSync(path.join(install_dir, 'SKILL.md'), '# stub')
57
+ const [err, result] = await verify_lock_disk_consistency({ version: '1.0.0' }, install_dir)
58
+ assert.strictEqual(err, null)
59
+ assert.deepEqual(result, { ok: false, reason: 'missing_skill_json', expected: '1.0.0', actual: null })
60
+ } finally { cleanup(dir) }
61
+ })
62
+
63
+ it('detects missing_skill_json when skill.json is unreadable JSON', async () => {
64
+ const dir = make_tmp()
65
+ try {
66
+ const install_dir = path.join(dir, 'corrupt-skill')
67
+ fs.mkdirSync(install_dir)
68
+ fs.writeFileSync(path.join(install_dir, 'skill.json'), '{ this is not json')
69
+ const [err, result] = await verify_lock_disk_consistency({ version: '1.0.0' }, install_dir)
70
+ assert.strictEqual(err, null)
71
+ assert.deepEqual(result, { ok: false, reason: 'missing_skill_json', expected: '1.0.0', actual: null })
72
+ } finally { cleanup(dir) }
73
+ })
74
+
75
+ it('detects version_mismatch when disk skill.json has no version field', async () => {
76
+ const dir = make_tmp()
77
+ try {
78
+ const install_dir = path.join(dir, 'no-version')
79
+ write_skill_json(install_dir, { name: 'no-version' })
80
+ const [err, result] = await verify_lock_disk_consistency({ version: '1.0.0' }, install_dir)
81
+ assert.strictEqual(err, null)
82
+ assert.deepEqual(result, { ok: false, reason: 'version_mismatch', expected: '1.0.0', actual: null })
83
+ } finally { cleanup(dir) }
84
+ })
85
+
86
+ it('returns ok when lock entry has no version (nothing to verify against)', async () => {
87
+ const dir = make_tmp()
88
+ try {
89
+ const install_dir = path.join(dir, 'unversioned')
90
+ const [err, result] = await verify_lock_disk_consistency({}, install_dir)
91
+ assert.strictEqual(err, null)
92
+ assert.deepEqual(result, { ok: true })
93
+ } finally { cleanup(dir) }
94
+ })
95
+
96
+ it('returns ok when lock entry is null (nothing to verify)', async () => {
97
+ const dir = make_tmp()
98
+ try {
99
+ const install_dir = path.join(dir, 'no-lock')
100
+ const [err, result] = await verify_lock_disk_consistency(null, install_dir)
101
+ assert.strictEqual(err, null)
102
+ assert.deepEqual(result, { ok: true })
103
+ } finally { cleanup(dir) }
104
+ })
105
+ })
106
+
107
+ describe('describe_drift', () => {
108
+ it('returns null for ok results', () => {
109
+ assert.strictEqual(describe_drift({ ok: true }), null)
110
+ assert.strictEqual(describe_drift(null), null)
111
+ })
112
+
113
+ it('describes version_mismatch with both versions', () => {
114
+ assert.strictEqual(
115
+ describe_drift({ ok: false, reason: 'version_mismatch', expected: '0.4.0', actual: '0.3.0' }),
116
+ 'drift (lock 0.4.0, disk 0.3.0)'
117
+ )
118
+ })
119
+
120
+ it('describes missing_dir', () => {
121
+ assert.strictEqual(
122
+ describe_drift({ ok: false, reason: 'missing_dir', expected: '1.0.0', actual: null }),
123
+ 'drift (lock 1.0.0, skill not on disk)'
124
+ )
125
+ })
126
+
127
+ it('describes missing_skill_json', () => {
128
+ assert.strictEqual(
129
+ describe_drift({ ok: false, reason: 'missing_skill_json', expected: '1.0.0', actual: null }),
130
+ 'drift (lock 1.0.0, no skill.json on disk)'
131
+ )
132
+ })
133
+
134
+ it('falls back to bare drift for unknown reasons', () => {
135
+ assert.strictEqual(describe_drift({ ok: false, reason: 'something_new' }), 'drift')
136
+ })
137
+ })