happyskills 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.22.0] - 2026-03-30
11
+
12
+ ### Added
13
+ - Add remote version check to `bump` — warns if the target version already exists on the registry before proceeding
14
+ - Add per-file merge strategy to `pull` — use `--theirs SKILL.md,skill.json --ours references/foo.md` to resolve conflicts file-by-file instead of all-or-nothing
15
+ - Add `--strict` flag to `pull` — fails on incompatible dependency ranges instead of warning
16
+ - Add dependency range validation to `pull` — warns when an installed dependency version falls outside the constraint declared in the merged `skill.json`
17
+
18
+ ### Changed
19
+ - Move auth verification earlier in `publish` — `list_workspaces()` now runs immediately after `require_token()`, failing fast on invalid/expired tokens before validation or upload
20
+ - Cache `hash_directory` results in memory within a single command invocation — prevents redundant disk reads when `status` or `refresh` checks multiple skills
21
+
22
+ ## [0.21.0] - 2026-03-30
23
+
24
+ ### Changed
25
+ - Upgrade `check` and `refresh` from semver-only to commit-level comparison (`base_commit` vs remote head) for accurate divergence detection — catches divergence even when version strings match (e.g., after force-publish). Falls back to version comparison for old lock files without `base_commit`.
26
+
27
+ ### Added
28
+ - Add `conflicts` status to `check` command — detects unresolved merge conflicts from the lock file's `conflict_files` field without disk I/O. JSON output now includes `conflicts_count`.
29
+ - Add local modification protection to `refresh` — skills with local edits are skipped with a warning and `happyskills pull` suggestion instead of being silently overwritten. JSON output includes `skipped` array with `{ skill, reason, suggestion }` per entry.
30
+
10
31
  ## [0.20.0] - 2026-03-30
11
32
 
12
33
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -8,7 +8,8 @@ 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 { print_help, print_success, print_json } = require('../ui/output')
11
+ const repos_api = require('../api/repos')
12
+ const { print_help, print_success, print_warn, print_json } = require('../ui/output')
12
13
  const { exit_with_error, UsageError } = require('../utils/errors')
13
14
  const { EXIT_CODES } = require('../constants')
14
15
 
@@ -50,6 +51,17 @@ const run = (args) => catch_errors('Bump failed', async () => {
50
51
 
51
52
  const old_version = manifest.version
52
53
 
54
+ // Read lock file early to resolve full skill name for remote check + lock update
55
+ const project_root = find_project_root()
56
+ const [lock_err, lock_data] = await read_lock(project_root)
57
+ let lock_key = null
58
+ let all_skills = null
59
+ if (!lock_err && lock_data) {
60
+ all_skills = get_all_locked_skills(lock_data)
61
+ const suffix = `/${skill_name}`
62
+ lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix)) || null
63
+ }
64
+
53
65
  if (BUMP_TYPES.includes(input)) {
54
66
  const new_version = inc(old_version, input)
55
67
  if (!new_version) throw new Error(`Failed to bump version from ${old_version}`)
@@ -60,6 +72,15 @@ const run = (args) => catch_errors('Bump failed', async () => {
60
72
  manifest.version = cleaned
61
73
  }
62
74
 
75
+ // Warn if target version already exists remotely
76
+ if (lock_key) {
77
+ const [, batch_data] = await repos_api.check_updates([lock_key])
78
+ const info = batch_data?.results?.[lock_key]
79
+ if (info?.latest_version === manifest.version) {
80
+ print_warn(`Version ${manifest.version} already exists remotely for ${lock_key}. Publishing will fail with VERSION_EXISTS.`)
81
+ }
82
+ }
83
+
63
84
  const validation = validate_manifest(manifest)
64
85
  if (!validation.valid) {
65
86
  throw new Error(`Invalid manifest after bump: ${validation.errors.join(', ')}`)
@@ -68,23 +89,16 @@ const run = (args) => catch_errors('Bump failed', async () => {
68
89
  const [write_err] = await write_manifest(dir, manifest)
69
90
  if (write_err) throw e('Failed to update skill.json', write_err)
70
91
 
71
- const project_root = find_project_root()
72
- const [lock_err, lock_data] = await read_lock(project_root)
73
- if (!lock_err && lock_data) {
74
- const all_skills = get_all_locked_skills(lock_data)
75
- const suffix = `/${skill_name}`
76
- const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
77
- if (lock_key && all_skills[lock_key]) {
78
- const [hash_err, integrity] = await hash_directory(dir)
79
- const updated_entry = {
80
- ...all_skills[lock_key],
81
- version: manifest.version,
82
- ref: `refs/tags/v${manifest.version}`
83
- }
84
- if (!hash_err && integrity) updated_entry.integrity = integrity
85
- const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
86
- await write_lock(project_root, updated_skills)
92
+ if (lock_key && all_skills?.[lock_key]) {
93
+ const [hash_err, integrity] = await hash_directory(dir)
94
+ const updated_entry = {
95
+ ...all_skills[lock_key],
96
+ version: manifest.version,
97
+ ref: `refs/tags/v${manifest.version}`
87
98
  }
99
+ if (!hash_err && integrity) updated_entry.integrity = integrity
100
+ const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
101
+ await write_lock(project_root, updated_skills)
88
102
  }
89
103
 
90
104
  if (args.flags.json) {
@@ -1,7 +1,6 @@
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 repos_api = require('../api/repos')
4
- const { gt } = require('../utils/semver')
5
4
  const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
6
5
  const { green, yellow, red } = require('../ui/colors')
7
6
  const { exit_with_error } = require('../utils/errors')
@@ -68,13 +67,17 @@ const run = (args) => catch_errors('Check failed', async () => {
68
67
  } else {
69
68
  for (const [name, data] of to_check) {
70
69
  const info = batch_data?.results?.[name]
71
- if (info?.access_denied) {
70
+ const has_conflicts = (data.conflict_files || []).length > 0
71
+ if (has_conflicts) {
72
+ results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'conflicts' })
73
+ } else if (info?.access_denied) {
72
74
  results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
73
75
  } else if (!info || !info.latest_version) {
74
76
  results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
75
- } else if (info.latest_version === data.version) {
76
- results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
77
- } else if (gt(info.latest_version, data.version)) {
77
+ } else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
78
+ results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
79
+ } else if (!data.base_commit && info.latest_version !== data.version) {
80
+ // Fallback to version comparison for old lock files without base_commit
78
81
  results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
79
82
  } else {
80
83
  results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
@@ -85,13 +88,15 @@ const run = (args) => catch_errors('Check failed', async () => {
85
88
  if (args.flags.json) {
86
89
  const outdated_count = results.filter(r => r.status === 'outdated').length
87
90
  const up_to_date_count = results.filter(r => r.status === 'up-to-date').length
88
- print_json({ data: { results, outdated_count, up_to_date_count } })
91
+ const conflicts_count = results.filter(r => r.status === 'conflicts').length
92
+ print_json({ data: { results, outdated_count, up_to_date_count, conflicts_count } })
89
93
  return
90
94
  }
91
95
 
92
96
  const status_colors = {
93
97
  'up-to-date': green,
94
98
  'outdated': yellow,
99
+ 'conflicts': red,
95
100
  'no-access': yellow,
96
101
  'error': red,
97
102
  'unknown': (s) => s
@@ -107,11 +112,17 @@ const run = (args) => catch_errors('Check failed', async () => {
107
112
  print_table(['Skill', 'Installed', 'Latest', 'Status'], rows)
108
113
 
109
114
  const outdated = results.filter(r => r.status === 'outdated')
115
+ const conflicts = results.filter(r => r.status === 'conflicts')
110
116
  const no_access = results.filter(r => r.status === 'no-access')
117
+ if (conflicts.length > 0) {
118
+ console.log()
119
+ print_warn(`${conflicts.length} skill(s) have unresolved merge conflicts.`)
120
+ print_hint(`Run ${code('happyskills status')} for details.`)
121
+ }
111
122
  if (outdated.length > 0) {
112
123
  console.log()
113
124
  print_info(`Run ${code('happyskills update')} to upgrade ${outdated.length} skill(s).`)
114
- } else if (results.every(r => r.status === 'up-to-date')) {
125
+ } else if (conflicts.length === 0 && results.every(r => r.status === 'up-to-date')) {
115
126
  console.log()
116
127
  print_success('All skills are up to date.')
117
128
  }
@@ -67,6 +67,10 @@ const run = (args) => catch_errors('Publish failed', async () => {
67
67
 
68
68
  await require_token()
69
69
 
70
+ // Verify auth early — fail fast before validation and upload
71
+ const [ws_err, workspaces] = await workspaces_api.list_workspaces()
72
+ if (ws_err) throw e('Authentication failed — run \'happyskills login\' to re-authenticate.', ws_err)
73
+
70
74
  const [dir_err, dir] = await resolve_skill_dir(skill_name)
71
75
  if (dir_err) throw e(`Skill "${skill_name}" not found`, dir_err)
72
76
 
@@ -133,9 +137,6 @@ const run = (args) => catch_errors('Publish failed', async () => {
133
137
  if (resolved_owner) owner = resolved_owner
134
138
  }
135
139
 
136
- const [ws_err, workspaces] = await workspaces_api.list_workspaces()
137
- if (ws_err) { spinner.fail('Failed to list workspaces'); throw e('Failed to list workspaces', ws_err) }
138
-
139
140
  const workspace = choose_workspace(workspaces, owner)
140
141
 
141
142
  if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) {
@@ -10,6 +10,7 @@ const { three_way_merge } = require('../merge/text_merge')
10
10
  const { merge_skill_json } = require('../merge/json_merge')
11
11
  const { merge_changelog } = require('../merge/changelog_merge')
12
12
  const { hash_blob } = require('../utils/git_hash')
13
+ const { satisfies } = require('../utils/semver')
13
14
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
14
15
  const { write_lock, update_lock_skills } = require('../lock/writer')
15
16
  const { hash_directory } = require('../lock/integrity')
@@ -28,16 +29,18 @@ Arguments:
28
29
  owner/skill Skill to pull (required)
29
30
 
30
31
  Options:
31
- --theirs Take remote version on conflicts
32
- --ours Keep local version on conflicts
32
+ --theirs [files] Take remote version on conflicts (all, or comma-separated file list)
33
+ --ours [files] Keep local version on conflicts (all, or comma-separated file list)
33
34
  --force Discard all local changes, take remote entirely
34
35
  -g, --global Pull globally installed skill
36
+ --strict Fail on incompatible dependency ranges instead of warning
35
37
  --json Output as JSON
36
38
  --full-report Include inline file content and resolution steps in JSON output (for AI agent review)
37
39
 
38
40
  Examples:
39
41
  happyskills pull acme/deploy-aws
40
42
  happyskills pull acme/deploy-aws --theirs
43
+ happyskills pull acme/deploy-aws --theirs SKILL.md,skill.json --ours references/foo.md
41
44
  happyskills pull acme/deploy-aws --json --full-report`
42
45
 
43
46
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -105,8 +108,32 @@ const run = (args) => catch_errors('Pull failed', async () => {
105
108
  }
106
109
 
107
110
  const is_global = args.flags.global || false
108
- const strategy = args.flags.theirs ? 'theirs' : args.flags.ours ? 'ours' : args.flags.force ? 'force' : null
109
111
  const full_report = !!(args.flags.json && args.flags['full-report'])
112
+
113
+ // Parse per-file strategies: --theirs SKILL.md,skill.json --ours references/foo.md
114
+ const theirs_files = typeof args.flags.theirs === 'string'
115
+ ? new Set(args.flags.theirs.split(',').map(s => s.trim()))
116
+ : null
117
+ const ours_files = typeof args.flags.ours === 'string'
118
+ ? new Set(args.flags.ours.split(',').map(s => s.trim()))
119
+ : null
120
+
121
+ // Validate no path appears in both --theirs and --ours
122
+ if (theirs_files && ours_files) {
123
+ for (const p of theirs_files) {
124
+ if (ours_files.has(p)) throw new UsageError(`"${p}" cannot be in both --theirs and --ours.`)
125
+ }
126
+ }
127
+
128
+ // Global strategy (boolean flags only, backward compatible)
129
+ const strategy = args.flags.theirs === true ? 'theirs' : args.flags.ours === true ? 'ours' : args.flags.force ? 'force' : null
130
+
131
+ // Per-file strategy resolver: per-file overrides take precedence over global
132
+ const file_strategy = (file_path) => {
133
+ if (theirs_files?.has(file_path)) return 'theirs'
134
+ if (ours_files?.has(file_path)) return 'ours'
135
+ return strategy
136
+ }
110
137
  const project_root = find_project_root()
111
138
 
112
139
  // 1. Read lock file
@@ -212,17 +239,33 @@ const run = (args) => catch_errors('Pull failed', async () => {
212
239
  const classified = classify_changes(base_files, local_files, remote_files)
213
240
  const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
214
241
 
242
+ // Validate per-file strategy paths against actual conflicts
243
+ if (theirs_files || ours_files) {
244
+ const both_paths = new Set(classified.both_modified.map(e => e.path))
245
+ for (const p of (theirs_files || [])) {
246
+ if (!both_paths.has(p)) print_warn(`--theirs path "${p}" not found in conflicted files — ignored.`)
247
+ }
248
+ for (const p of (ours_files || [])) {
249
+ if (!both_paths.has(p)) print_warn(`--ours path "${p}" not found in conflicted files — ignored.`)
250
+ }
251
+ }
252
+
253
+ // Split both_modified by resolved strategy
254
+ const auto_merge_entries = classified.both_modified.filter(e => !file_strategy(e.path))
255
+ const theirs_entries = classified.both_modified.filter(e => file_strategy(e.path) === 'theirs')
256
+ const ours_entries = classified.both_modified.filter(e => file_strategy(e.path) === 'ours')
257
+
215
258
  // Apply changes
216
259
  spinner.update('Applying changes...')
217
260
  const base_file_map = new Map((base_clone.files || []).map(f => [f.path, f]))
218
261
  const remote_file_map = new Map((remote_clone.files || []).map(f => [f.path, f]))
219
262
 
220
- // Auto-merge both_modified files when no strategy is set
263
+ // Auto-merge both_modified files that have no explicit strategy
221
264
  const conflict_files = []
222
265
  const json_conflicts = []
223
- if (classified.both_modified.length > 0 && !strategy) {
266
+ if (auto_merge_entries.length > 0) {
224
267
  spinner.update('Auto-merging...')
225
- for (const entry of classified.both_modified) {
268
+ for (const entry of auto_merge_entries) {
226
269
  const report_file = report.files.find(f => f.path === entry.path)
227
270
 
228
271
  // Delete-vs-modify — cannot auto-merge, keep what exists
@@ -333,9 +376,9 @@ const run = (args) => catch_errors('Pull failed', async () => {
333
376
  }
334
377
  }
335
378
 
336
- // Both-modified → apply strategy
337
- if (strategy === 'theirs') {
338
- for (const entry of classified.both_modified) {
379
+ // Both-modified → apply explicit strategy (global or per-file)
380
+ if (theirs_entries.length > 0) {
381
+ for (const entry of theirs_entries) {
339
382
  if (full_report) {
340
383
  const report_file = report.files.find(f => f.path === entry.path)
341
384
  if (report_file) {
@@ -362,8 +405,8 @@ const run = (args) => catch_errors('Pull failed', async () => {
362
405
  }
363
406
  }
364
407
  }
365
- if (strategy === 'ours' && full_report) {
366
- for (const entry of classified.both_modified) {
408
+ if (ours_entries.length > 0 && full_report) {
409
+ for (const entry of ours_entries) {
367
410
  const report_file = report.files.find(f => f.path === entry.path)
368
411
  if (report_file) {
369
412
  const content = { base: decode_b64(base_file_map.get(entry.path)), remote: decode_b64(remote_file_map.get(entry.path)) }
@@ -407,8 +450,10 @@ const run = (args) => catch_errors('Pull failed', async () => {
407
450
  if (report.summary.auto_merged > 0) {
408
451
  print_info(`${report.summary.auto_merged} file(s) auto-applied (non-conflicting changes)`)
409
452
  }
410
- if (classified.both_modified.length > 0 && strategy) {
411
- print_info(`${classified.both_modified.length} conflict(s) resolved with --${strategy}`)
453
+ const resolved_count = theirs_entries.length + ours_entries.length
454
+ if (resolved_count > 0) {
455
+ const label = strategy ? `--${strategy}` : 'per-file strategy'
456
+ print_info(`${resolved_count} conflict(s) resolved with ${label}`)
412
457
  }
413
458
  const auto_merged_count = classified.both_modified.length - conflict_files.length
414
459
  if (auto_merged_count > 0 && !strategy) {
@@ -457,7 +502,12 @@ const reconcile_dependencies = (skill_dir, skill_name, lock_data, is_global, pro
457
502
  to_install.push(dep)
458
503
  continue
459
504
  }
460
- // Installed version exists — skip (higher-version-wins is handled by the resolver)
505
+ // Check if installed version satisfies the new constraint
506
+ if (constraint && installed.version && !satisfies(installed.version, constraint)) {
507
+ const msg = `${dep}@${installed.version} is installed but ${skill_name} requires ${constraint}. Installed version is outside the declared range — verify compatibility.`
508
+ if (args.flags.strict) throw new Error(msg)
509
+ print_warn(msg)
510
+ }
461
511
  }
462
512
 
463
513
  if (to_install.length === 0) return
@@ -1,13 +1,13 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { install } = require('../engine/installer')
3
3
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
4
+ const { detect_status } = require('../merge/detector')
4
5
  const repos_api = require('../api/repos')
5
- const { gt } = require('../utils/semver')
6
6
  const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
7
7
  const { green, yellow, red } = require('../ui/colors')
8
8
  const { create_spinner } = require('../ui/spinner')
9
9
  const { exit_with_error } = require('../utils/errors')
10
- const { find_project_root, lock_root } = require('../config/paths')
10
+ const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
11
11
  const { EXIT_CODES } = require('../constants')
12
12
 
13
13
  const HELP_TEXT = `Usage: happyskills refresh [options]
@@ -78,9 +78,10 @@ const run = (args) => catch_errors('Refresh failed', async () => {
78
78
  results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
79
79
  } else if (!info || !info.latest_version) {
80
80
  results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
81
- } else if (info.latest_version === data.version) {
82
- results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
83
- } else if (gt(info.latest_version, data.version)) {
81
+ } else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
82
+ results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
83
+ } else if (!data.base_commit && info.latest_version !== data.version) {
84
+ // Fallback to version comparison for old lock files without base_commit
84
85
  results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
85
86
  } else {
86
87
  results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
@@ -125,12 +126,29 @@ const run = (args) => catch_errors('Refresh failed', async () => {
125
126
  }
126
127
  }
127
128
 
128
- // 5. Update outdated skills
129
+ // 5. Detect local modifications before updating
130
+ const base_dir = skills_dir(is_global, project_root)
131
+ const skipped = []
132
+ const safe_to_update = []
133
+
134
+ for (const r of outdated) {
135
+ const lock_entry = skills[r.skill]
136
+ const short_name = r.skill.split('/')[1] || r.skill
137
+ const dir = skill_install_dir(base_dir, short_name)
138
+ const [, det] = await detect_status(lock_entry, dir)
139
+ if (det?.local_modified) {
140
+ skipped.push({ skill: r.skill, reason: 'local_modifications', suggestion: `happyskills pull ${r.skill}` })
141
+ } else {
142
+ safe_to_update.push(r)
143
+ }
144
+ }
145
+
146
+ // 6. Update safe skills
129
147
  const options = { global: is_global, fresh: true, agents: args.flags.agents || undefined, project_root }
130
148
  const updated = []
131
149
  const update_errors = []
132
150
 
133
- for (const r of outdated) {
151
+ for (const r of safe_to_update) {
134
152
  const update_spinner = !args.flags.json ? create_spinner(`Updating ${r.skill}…`) : null
135
153
  const [errors, result] = await install(r.skill, options)
136
154
  if (errors) {
@@ -144,7 +162,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
144
162
  }
145
163
  }
146
164
 
147
- // 6. Output results
165
+ // 7. Output results
148
166
  if (args.flags.json) {
149
167
  print_json({
150
168
  data: {
@@ -152,6 +170,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
152
170
  outdated_count: outdated.length,
153
171
  up_to_date_count: up_to_date.length,
154
172
  updated,
173
+ skipped,
155
174
  already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
156
175
  errors: update_errors
157
176
  }
@@ -159,6 +178,14 @@ const run = (args) => catch_errors('Refresh failed', async () => {
159
178
  return
160
179
  }
161
180
 
181
+ if (skipped.length > 0) {
182
+ console.log()
183
+ print_warn(`Skipped ${skipped.length} skill(s) with local modifications:`)
184
+ for (const s of skipped) {
185
+ print_info(` ${s.skill}`)
186
+ }
187
+ print_hint(`Use ${code('happyskills pull <skill>')} to merge remote changes.`)
188
+ }
162
189
  if (updated.length > 0) {
163
190
  console.log()
164
191
  print_success(`Updated ${updated.length} skill(s).`)
@@ -8,7 +8,13 @@ const hash_file = (file_path) => catch_errors(`Failed to hash file ${file_path}`
8
8
  return crypto.createHash('sha256').update(content).digest('hex')
9
9
  })
10
10
 
11
+ const _hash_cache = new Map()
12
+
13
+ const clear_integrity_cache = () => _hash_cache.clear()
14
+
11
15
  const hash_directory = (dir_path) => catch_errors(`Failed to hash directory ${dir_path}`, async () => {
16
+ if (_hash_cache.has(dir_path)) return _hash_cache.get(dir_path)
17
+
12
18
  const hash = crypto.createHash('sha256')
13
19
  const entries = await collect_files(dir_path)
14
20
 
@@ -20,7 +26,9 @@ const hash_directory = (dir_path) => catch_errors(`Failed to hash directory ${di
20
26
  hash.update(content)
21
27
  }
22
28
 
23
- return `sha256-${hash.digest('hex')}`
29
+ const result = `sha256-${hash.digest('hex')}`
30
+ _hash_cache.set(dir_path, result)
31
+ return result
24
32
  })
25
33
 
26
34
  const collect_files = async (dir_path, base_path = dir_path) => {
@@ -51,4 +59,4 @@ const verify_integrity = (dir_path, expected_hash) => catch_errors('Integrity ve
51
59
  return actual_hash === expected_hash
52
60
  })
53
61
 
54
- module.exports = { hash_file, hash_directory, verify_integrity }
62
+ module.exports = { hash_file, hash_directory, verify_integrity, clear_integrity_cache }
@@ -4,7 +4,7 @@ const fs = require('fs')
4
4
  const path = require('path')
5
5
  const os = require('os')
6
6
  const { detect_status } = require('./detector')
7
- const { hash_directory } = require('../lock/integrity')
7
+ const { hash_directory, clear_integrity_cache } = require('../lock/integrity')
8
8
 
9
9
  const make_tmp = () => {
10
10
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'detector-test-'))
@@ -39,8 +39,9 @@ describe('detect_status', () => {
39
39
 
40
40
  const [, integrity] = await hash_directory(tmp_dir)
41
41
 
42
- // Modify the file
42
+ // Modify the file and clear hash cache (cache is per-command-invocation optimization)
43
43
  fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'modified')
44
+ clear_integrity_cache()
44
45
 
45
46
  const lock_entry = { base_integrity: integrity, base_commit: 'abc123' }
46
47
  const [err, result] = await detect_status(lock_entry, tmp_dir)