happyskills 0.19.0 → 0.21.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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.21.0] - 2026-03-30
11
+
12
+ ### Changed
13
+ - 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`.
14
+
15
+ ### Added
16
+ - 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`.
17
+ - 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.
18
+
19
+ ## [0.20.0] - 2026-03-30
20
+
21
+ ### Added
22
+ - Add `--full-report` flag to `pull` command for AI agent semantic review (Layer 2 enablement) — when combined with `--json`, includes inline file content (`base_content`, `local_content`, `remote_content`, `merged_content`) for all changed files and action-typed `resolution_steps` (resolve_conflict_markers, review_json_suggestions, semantic_review, verify)
23
+ - Add `enrich_file_content()` and `build_resolution_steps()` to merge report module — deterministic resolution steps guide the consuming LLM through conflict resolution, JSON suggestion review, cross-file semantic consistency checks, and verification
24
+
10
25
  ## [0.19.0] - 2026-03-29
11
26
 
12
27
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.19.0",
3
+ "version": "0.21.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)",
@@ -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
  }
@@ -5,7 +5,7 @@ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
5
5
  const repos_api = require('../api/repos')
6
6
  const { detect_status } = require('../merge/detector')
7
7
  const { classify_changes } = require('../merge/comparator')
8
- const { build_report } = require('../merge/report')
8
+ const { build_report, enrich_file_content, build_resolution_steps } = require('../merge/report')
9
9
  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')
@@ -33,14 +33,17 @@ Options:
33
33
  --force Discard all local changes, take remote entirely
34
34
  -g, --global Pull globally installed skill
35
35
  --json Output as JSON
36
+ --full-report Include inline file content and resolution steps in JSON output (for AI agent review)
36
37
 
37
38
  Examples:
38
39
  happyskills pull acme/deploy-aws
39
40
  happyskills pull acme/deploy-aws --theirs
40
- happyskills pull acme/deploy-aws --force`
41
+ happyskills pull acme/deploy-aws --json --full-report`
41
42
 
42
43
  // ─── Helpers ──────────────────────────────────────────────────────────────────
43
44
 
45
+ const decode_b64 = (file) => file ? Buffer.from(file.content, 'base64').toString('utf-8') : undefined
46
+
44
47
  const build_local_entries = (skill_dir) => catch_errors('Failed to build local entries', async () => {
45
48
  const entries = []
46
49
  const walk = async (dir, prefix) => {
@@ -103,6 +106,7 @@ const run = (args) => catch_errors('Pull failed', async () => {
103
106
 
104
107
  const is_global = args.flags.global || false
105
108
  const strategy = args.flags.theirs ? 'theirs' : args.flags.ours ? 'ours' : args.flags.force ? 'force' : null
109
+ const full_report = !!(args.flags.json && args.flags['full-report'])
106
110
  const project_root = find_project_root()
107
111
 
108
112
  // 1. Read lock file
@@ -229,8 +233,21 @@ const run = (args) => catch_errors('Pull failed', async () => {
229
233
  // Local deleted, remote modified — write remote version
230
234
  const [af_err] = await apply_remote_file(skill_dir, owner, repo, entry)
231
235
  if (af_err) { spinner.fail(`Failed to apply ${entry.path}`); throw af_err[0] }
236
+ if (full_report && report_file) {
237
+ const rf = remote_file_map.get(entry.path)
238
+ enrich_file_content(report_file, {
239
+ base: decode_b64(base_file_map.get(entry.path)),
240
+ remote: decode_b64(rf)
241
+ })
242
+ }
243
+ } else if (full_report && report_file) {
244
+ // Local modified, remote deleted — capture what exists
245
+ const local_text = await fs.promises.readFile(path.join(skill_dir, entry.path), 'utf-8')
246
+ enrich_file_content(report_file, {
247
+ base: decode_b64(base_file_map.get(entry.path)),
248
+ local: local_text
249
+ })
232
250
  }
233
- // Local modified, remote deleted — keep local as-is
234
251
  continue
235
252
  }
236
253
 
@@ -246,9 +263,13 @@ const run = (args) => catch_errors('Pull failed', async () => {
246
263
  JSON.parse(local_text || '{}'),
247
264
  JSON.parse(remote_text || '{}')
248
265
  )
249
- await fs.promises.writeFile(path.join(skill_dir, entry.path), JSON.stringify(result.merged, null, '\t') + '\n')
266
+ const merged_text = JSON.stringify(result.merged, null, '\t') + '\n'
267
+ await fs.promises.writeFile(path.join(skill_dir, entry.path), merged_text)
250
268
  if (result.conflicts.length > 0) json_conflicts.push(...result.conflicts)
251
- if (report_file) report_file.merge_result = { type: 'json', conflicts: result.conflicts }
269
+ if (report_file) {
270
+ report_file.merge_result = { type: 'json', conflicts: result.conflicts }
271
+ if (full_report) enrich_file_content(report_file, { base: base_text, local: local_text, remote: remote_text, merged: merged_text })
272
+ }
252
273
  } else if (entry.path.toLowerCase() === 'changelog.md') {
253
274
  const result = merge_changelog(base_text, local_text, remote_text)
254
275
  await fs.promises.writeFile(path.join(skill_dir, entry.path), result.merged)
@@ -256,6 +277,7 @@ const run = (args) => catch_errors('Pull failed', async () => {
256
277
  if (report_file) {
257
278
  report_file.conflict_written = result.has_conflicts
258
279
  report_file.merge_result = { type: 'changelog', has_conflicts: result.has_conflicts, used_fallback: result.used_fallback }
280
+ if (full_report) enrich_file_content(report_file, { base: base_text, local: local_text, remote: remote_text, merged: result.merged })
259
281
  }
260
282
  } else {
261
283
  const result = three_way_merge(base_text, local_text, remote_text)
@@ -264,6 +286,7 @@ const run = (args) => catch_errors('Pull failed', async () => {
264
286
  if (report_file) {
265
287
  report_file.conflict_written = result.has_conflicts
266
288
  report_file.merge_result = { type: 'text', conflict_count: result.conflict_count, conflict_regions: result.conflict_regions }
289
+ if (full_report) enrich_file_content(report_file, { base: base_text, local: local_text, remote: remote_text, merged: result.merged })
267
290
  }
268
291
  }
269
292
  }
@@ -277,19 +300,53 @@ const run = (args) => catch_errors('Pull failed', async () => {
277
300
  const [dir_err] = await ensure_dir(path.dirname(full))
278
301
  if (dir_err) { spinner.fail(`Failed to create dir for ${entry.path}`); throw dir_err[0] }
279
302
  await fs.promises.writeFile(full, Buffer.from(remote_file.content, 'base64'))
303
+ if (full_report) {
304
+ const report_file = report.files.find(f => f.path === entry.path)
305
+ if (report_file) enrich_file_content(report_file, { remote: decode_b64(remote_file) })
306
+ }
280
307
  }
281
308
  }
282
309
 
283
310
  // Remote-only deleted → remove local
311
+ if (full_report) {
312
+ for (const entry of classified.remote_only_deleted) {
313
+ const report_file = report.files.find(f => f.path === entry.path)
314
+ if (report_file) enrich_file_content(report_file, { base: decode_b64(base_file_map.get(entry.path)) })
315
+ }
316
+ }
284
317
  const [del_err] = await remove_files(skill_dir, classified.remote_only_deleted.map(f => f.path))
285
318
  if (del_err) { spinner.fail('Failed to remove deleted files'); throw del_err[0] }
286
319
 
287
320
  // Local-only modified/added → keep (no action needed)
288
321
  // Local-only deleted → keep deleted (no action needed)
322
+ if (full_report) {
323
+ for (const entry of [...classified.local_only_modified, ...classified.local_only_added]) {
324
+ const report_file = report.files.find(f => f.path === entry.path)
325
+ if (report_file) {
326
+ const local_text = await fs.promises.readFile(path.join(skill_dir, entry.path), 'utf-8')
327
+ enrich_file_content(report_file, { local: local_text })
328
+ }
329
+ }
330
+ for (const entry of classified.local_only_deleted) {
331
+ const report_file = report.files.find(f => f.path === entry.path)
332
+ if (report_file) enrich_file_content(report_file, { base: decode_b64(base_file_map.get(entry.path)) })
333
+ }
334
+ }
289
335
 
290
336
  // Both-modified → apply strategy
291
337
  if (strategy === 'theirs') {
292
338
  for (const entry of classified.both_modified) {
339
+ if (full_report) {
340
+ const report_file = report.files.find(f => f.path === entry.path)
341
+ if (report_file) {
342
+ const content = { base: decode_b64(base_file_map.get(entry.path)), remote: decode_b64(remote_file_map.get(entry.path)) }
343
+ // Capture local before overwrite
344
+ if (entry.local_sha !== null) {
345
+ try { content.local = await fs.promises.readFile(path.join(skill_dir, entry.path), 'utf-8') } catch { /* deleted */ }
346
+ }
347
+ enrich_file_content(report_file, content)
348
+ }
349
+ }
293
350
  if (entry.remote_sha === null) {
294
351
  // Remote deleted — remove local
295
352
  const full = path.join(skill_dir, entry.path)
@@ -305,7 +362,18 @@ const run = (args) => catch_errors('Pull failed', async () => {
305
362
  }
306
363
  }
307
364
  }
308
- // strategy === 'ours' keep local files as-is (no action needed)
365
+ if (strategy === 'ours' && full_report) {
366
+ for (const entry of classified.both_modified) {
367
+ const report_file = report.files.find(f => f.path === entry.path)
368
+ if (report_file) {
369
+ const content = { base: decode_b64(base_file_map.get(entry.path)), remote: decode_b64(remote_file_map.get(entry.path)) }
370
+ if (entry.local_sha !== null) {
371
+ try { content.local = await fs.promises.readFile(path.join(skill_dir, entry.path), 'utf-8') } catch { /* deleted */ }
372
+ }
373
+ enrich_file_content(report_file, content)
374
+ }
375
+ }
376
+ }
309
377
 
310
378
  // Update lock — use head_commit from compare (authoritative, not cached)
311
379
  const [hash_err, new_integrity] = await hash_directory(skill_dir)
@@ -331,7 +399,9 @@ const run = (args) => catch_errors('Pull failed', async () => {
331
399
 
332
400
  if (args.flags.json) {
333
401
  const status = conflict_files.length > 0 ? 'conflicts' : 'merged'
334
- print_json({ data: { status, report, conflict_files, json_conflicts } })
402
+ const output = { status, report, conflict_files, json_conflicts }
403
+ if (full_report) output.resolution_steps = build_resolution_steps(report, json_conflicts)
404
+ print_json({ data: output })
335
405
  } else {
336
406
  print_success(`Pulled ${skill_name} → ${cmp_data.head_version || 'latest'}`)
337
407
  if (report.summary.auto_merged > 0) {
@@ -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).`)
@@ -4,6 +4,10 @@
4
4
  * The report is consumed by both human-readable CLI output and the AI agent
5
5
  * semantic review layer (Layer 2). It must include enough data for the agent
6
6
  * to reason about conflicts without additional API calls.
7
+ *
8
+ * When --full-report is used, file entries include inline content fields
9
+ * (base_content, local_content, remote_content, merged_content) so the
10
+ * consuming LLM can perform semantic review in a single pass.
7
11
  */
8
12
 
9
13
  const CLASSIFICATIONS = [
@@ -13,6 +17,12 @@ const CLASSIFICATIONS = [
13
17
  'unchanged'
14
18
  ]
15
19
 
20
+ const SEMANTIC_REVIEW_CLASSIFICATIONS = new Set([
21
+ 'both_modified',
22
+ 'remote_only_modified', 'local_only_modified',
23
+ 'remote_only_added', 'local_only_added'
24
+ ])
25
+
16
26
  const build_report = (skill, base_version, remote_version, classified) => {
17
27
  const files = []
18
28
  let clean = 0
@@ -56,4 +66,79 @@ const build_report = (skill, base_version, remote_version, classified) => {
56
66
  }
57
67
  }
58
68
 
59
- module.exports = { build_report, CLASSIFICATIONS }
69
+ /**
70
+ * Enrich a report file entry with inline content for --full-report mode.
71
+ * Only sets fields that are provided (non-undefined).
72
+ *
73
+ * @param {object} report_file - A file entry from report.files
74
+ * @param {{ base?: string, local?: string, remote?: string, merged?: string }} content
75
+ */
76
+ const enrich_file_content = (report_file, content) => {
77
+ if (content.base !== undefined) report_file.base_content = content.base
78
+ if (content.local !== undefined) report_file.local_content = content.local
79
+ if (content.remote !== undefined) report_file.remote_content = content.remote
80
+ if (content.merged !== undefined) report_file.merged_content = content.merged
81
+ }
82
+
83
+ /**
84
+ * Build action-typed resolution steps from the merge report.
85
+ * Steps are deterministic — computed from classification data and merge results.
86
+ * The consuming LLM adds intelligence (semantic reasoning, natural language).
87
+ *
88
+ * @param {object} report - The merge report from build_report
89
+ * @param {Array} [json_conflicts] - Conflicts from skill.json merge
90
+ * @returns {Array} Ordered resolution steps
91
+ */
92
+ const build_resolution_steps = (report, json_conflicts = []) => {
93
+ const steps = []
94
+
95
+ // 1. Conflict markers — must be resolved before publishing
96
+ const marker_files = report.files.filter(f => f.conflict_written)
97
+ if (marker_files.length > 0) {
98
+ steps.push({
99
+ action: 'resolve_conflict_markers',
100
+ files: marker_files.map(f => f.path),
101
+ description: 'Files with conflict markers that must be manually resolved'
102
+ })
103
+ }
104
+
105
+ // 2. JSON field suggestions — review auto-applied defaults
106
+ if (json_conflicts.length > 0) {
107
+ steps.push({
108
+ action: 'review_json_suggestions',
109
+ files: ['skill.json'],
110
+ suggestions: json_conflicts.map(c => ({
111
+ field: c.field,
112
+ value: c.suggestion,
113
+ reason: suggestion_reason(c.field)
114
+ }))
115
+ })
116
+ }
117
+
118
+ // 3. Semantic review — all files where concurrent changes may contradict
119
+ const semantic_files = report.files.filter(f => SEMANTIC_REVIEW_CLASSIFICATIONS.has(f.classification))
120
+ if (semantic_files.length > 0) {
121
+ steps.push({
122
+ action: 'semantic_review',
123
+ files: semantic_files.map(f => f.path),
124
+ description: 'Review these files for logical consistency — changes from both sides may introduce semantic contradictions even without text conflicts'
125
+ })
126
+ }
127
+
128
+ // 4. Verify — always present as final step
129
+ steps.push({
130
+ action: 'verify',
131
+ command: 'happyskills status',
132
+ description: 'Verify all conflicts are resolved before publishing'
133
+ })
134
+
135
+ return steps
136
+ }
137
+
138
+ const suggestion_reason = (field) => {
139
+ if (field === 'version') return 'Next patch after the higher version'
140
+ if (field.startsWith('dependencies.')) return 'Published version wins by default'
141
+ return 'Remote value used as default'
142
+ }
143
+
144
+ module.exports = { build_report, enrich_file_content, build_resolution_steps, CLASSIFICATIONS }
@@ -1,19 +1,26 @@
1
1
  const { describe, it } = require('node:test')
2
2
  const assert = require('node:assert/strict')
3
- const { build_report, CLASSIFICATIONS } = require('./report')
3
+ const { build_report, enrich_file_content, build_resolution_steps, CLASSIFICATIONS } = require('./report')
4
+
5
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
6
+
7
+ const make_classified = (overrides = {}) => ({
8
+ remote_only_modified: [], local_only_modified: [], both_modified: [],
9
+ remote_only_added: [], local_only_added: [],
10
+ remote_only_deleted: [], local_only_deleted: [],
11
+ unchanged: [],
12
+ ...overrides
13
+ })
14
+
15
+ // ─── build_report ─────────────────────────────────────────────────────────────
4
16
 
5
17
  describe('build_report', () => {
6
18
  it('produces v1 contract fields', () => {
7
- const classified = {
19
+ const classified = make_classified({
8
20
  remote_only_modified: [{ path: 'a.md', base_sha: '111', remote_sha: '222' }],
9
- local_only_modified: [],
10
21
  both_modified: [{ path: 'b.md', base_sha: '333', local_sha: '444', remote_sha: '555' }],
11
- remote_only_added: [],
12
- local_only_added: [],
13
- remote_only_deleted: [],
14
- local_only_deleted: [],
15
22
  unchanged: [{ path: 'c.md', sha: '666' }]
16
- }
23
+ })
17
24
  const report = build_report('acme/deploy-aws', '1.15.0', '1.17.0', classified)
18
25
 
19
26
  assert.strictEqual(report.skill, 'acme/deploy-aws')
@@ -24,16 +31,13 @@ describe('build_report', () => {
24
31
  })
25
32
 
26
33
  it('summary counts match file array', () => {
27
- const classified = {
34
+ const classified = make_classified({
28
35
  remote_only_modified: [{ path: 'a.md', base_sha: '1', remote_sha: '2' }],
29
36
  local_only_modified: [{ path: 'b.md', base_sha: '3', local_sha: '4' }],
30
- both_modified: [],
31
37
  remote_only_added: [{ path: 'c.md', remote_sha: '5' }],
32
- local_only_added: [],
33
38
  remote_only_deleted: [{ path: 'd.md' }],
34
- local_only_deleted: [],
35
39
  unchanged: [{ path: 'e.md', sha: '6' }, { path: 'f.md', sha: '7' }]
36
- }
40
+ })
37
41
  const report = build_report('test/skill', '1.0.0', '2.0.0', classified)
38
42
 
39
43
  assert.strictEqual(report.files.length, report.summary.auto_merged + report.summary.conflicted)
@@ -41,16 +45,10 @@ describe('build_report', () => {
41
45
  })
42
46
 
43
47
  it('classifications are from the defined vocabulary', () => {
44
- const classified = {
48
+ const classified = make_classified({
45
49
  remote_only_modified: [{ path: 'a.md', base_sha: '1', remote_sha: '2' }],
46
- local_only_modified: [],
47
50
  both_modified: [{ path: 'b.md', base_sha: '3', local_sha: '4', remote_sha: '5' }],
48
- remote_only_added: [],
49
- local_only_added: [],
50
- remote_only_deleted: [],
51
- local_only_deleted: [],
52
- unchanged: []
53
- }
51
+ })
54
52
  const report = build_report('test/skill', '1.0.0', '2.0.0', classified)
55
53
 
56
54
  for (const file of report.files) {
@@ -59,12 +57,9 @@ describe('build_report', () => {
59
57
  })
60
58
 
61
59
  it('handles null versions gracefully', () => {
62
- const classified = {
63
- remote_only_modified: [], local_only_modified: [], both_modified: [],
64
- remote_only_added: [], local_only_added: [],
65
- remote_only_deleted: [], local_only_deleted: [],
60
+ const classified = make_classified({
66
61
  unchanged: [{ path: 'a.md', sha: '111' }]
67
- }
62
+ })
68
63
  const report = build_report('test/skill', null, null, classified)
69
64
  assert.strictEqual(report.base_version, null)
70
65
  assert.strictEqual(report.remote_version, null)
@@ -72,14 +67,169 @@ describe('build_report', () => {
72
67
  })
73
68
 
74
69
  it('unchanged files are not included in files array', () => {
75
- const classified = {
76
- remote_only_modified: [], local_only_modified: [], both_modified: [],
77
- remote_only_added: [], local_only_added: [],
78
- remote_only_deleted: [], local_only_deleted: [],
70
+ const classified = make_classified({
79
71
  unchanged: [{ path: 'a.md', sha: '111' }, { path: 'b.md', sha: '222' }]
80
- }
72
+ })
81
73
  const report = build_report('test/skill', '1.0.0', '1.0.0', classified)
82
74
  assert.strictEqual(report.files.length, 0)
83
75
  assert.strictEqual(report.summary.clean, 2)
84
76
  })
85
77
  })
78
+
79
+ // ─── enrich_file_content ──────────────────────────────────────────────────────
80
+
81
+ describe('enrich_file_content', () => {
82
+ it('sets all provided content fields', () => {
83
+ const file = { path: 'SKILL.md', classification: 'both_modified' }
84
+ enrich_file_content(file, {
85
+ base: 'base text',
86
+ local: 'local text',
87
+ remote: 'remote text',
88
+ merged: 'merged text'
89
+ })
90
+ assert.strictEqual(file.base_content, 'base text')
91
+ assert.strictEqual(file.local_content, 'local text')
92
+ assert.strictEqual(file.remote_content, 'remote text')
93
+ assert.strictEqual(file.merged_content, 'merged text')
94
+ })
95
+
96
+ it('only sets fields that are provided (non-undefined)', () => {
97
+ const file = { path: 'a.md', classification: 'remote_only_modified' }
98
+ enrich_file_content(file, { remote: 'remote text' })
99
+ assert.strictEqual(file.remote_content, 'remote text')
100
+ assert.strictEqual(file.base_content, undefined)
101
+ assert.strictEqual(file.local_content, undefined)
102
+ assert.strictEqual(file.merged_content, undefined)
103
+ })
104
+
105
+ it('does not set fields for undefined values', () => {
106
+ const file = { path: 'a.md' }
107
+ enrich_file_content(file, { base: undefined, local: 'text' })
108
+ assert.ok(!('base_content' in file))
109
+ assert.strictEqual(file.local_content, 'text')
110
+ })
111
+
112
+ it('handles empty string content', () => {
113
+ const file = { path: 'a.md' }
114
+ enrich_file_content(file, { base: '', remote: '' })
115
+ assert.strictEqual(file.base_content, '')
116
+ assert.strictEqual(file.remote_content, '')
117
+ })
118
+ })
119
+
120
+ // ─── build_resolution_steps ───────────────────────────────────────────────────
121
+
122
+ describe('build_resolution_steps', () => {
123
+ it('always includes verify step as last step', () => {
124
+ const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
125
+ unchanged: [{ path: 'a.md', sha: '111' }]
126
+ }))
127
+ const steps = build_resolution_steps(report)
128
+ assert.strictEqual(steps.length, 1)
129
+ assert.strictEqual(steps[0].action, 'verify')
130
+ assert.strictEqual(steps[0].command, 'happyskills status')
131
+ })
132
+
133
+ it('includes resolve_conflict_markers for files with conflict_written', () => {
134
+ const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
135
+ both_modified: [
136
+ { path: 'SKILL.md', base_sha: '1', local_sha: '2', remote_sha: '3' },
137
+ { path: 'ref.md', base_sha: '4', local_sha: '5', remote_sha: '6' }
138
+ ]
139
+ }))
140
+ // Simulate pull.js marking conflict_written
141
+ report.files[0].conflict_written = true
142
+
143
+ const steps = build_resolution_steps(report)
144
+ const marker_step = steps.find(s => s.action === 'resolve_conflict_markers')
145
+ assert.ok(marker_step)
146
+ assert.deepStrictEqual(marker_step.files, ['SKILL.md'])
147
+ })
148
+
149
+ it('includes review_json_suggestions when json_conflicts exist', () => {
150
+ const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
151
+ both_modified: [{ path: 'skill.json', base_sha: '1', local_sha: '2', remote_sha: '3' }]
152
+ }))
153
+ const json_conflicts = [
154
+ { field: 'version', base_value: '1.0.0', local_value: '1.1.0', remote_value: '1.2.0', suggestion: '1.2.1' },
155
+ { field: 'dependencies.acme/utils', base_value: '1.0.0', local_value: '2.0.0', remote_value: '3.0.0', suggestion: '3.0.0' }
156
+ ]
157
+
158
+ const steps = build_resolution_steps(report, json_conflicts)
159
+ const json_step = steps.find(s => s.action === 'review_json_suggestions')
160
+ assert.ok(json_step)
161
+ assert.deepStrictEqual(json_step.files, ['skill.json'])
162
+ assert.strictEqual(json_step.suggestions.length, 2)
163
+ assert.strictEqual(json_step.suggestions[0].field, 'version')
164
+ assert.strictEqual(json_step.suggestions[0].value, '1.2.1')
165
+ assert.strictEqual(json_step.suggestions[0].reason, 'Next patch after the higher version')
166
+ assert.strictEqual(json_step.suggestions[1].reason, 'Published version wins by default')
167
+ })
168
+
169
+ it('includes semantic_review for all modified/added classifications', () => {
170
+ const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
171
+ both_modified: [{ path: 'SKILL.md', base_sha: '1', local_sha: '2', remote_sha: '3' }],
172
+ remote_only_modified: [{ path: 'refs/auth.md', base_sha: '4', remote_sha: '5' }],
173
+ local_only_modified: [{ path: 'refs/deploy.md', base_sha: '6', local_sha: '7' }],
174
+ remote_only_added: [{ path: 'refs/new-remote.md', remote_sha: '8' }],
175
+ local_only_added: [{ path: 'refs/new-local.md', local_sha: '9' }],
176
+ remote_only_deleted: [{ path: 'old.md', base_sha: '10' }],
177
+ local_only_deleted: [{ path: 'legacy.md', base_sha: '11' }]
178
+ }))
179
+
180
+ const steps = build_resolution_steps(report)
181
+ const semantic_step = steps.find(s => s.action === 'semantic_review')
182
+ assert.ok(semantic_step)
183
+ // Should include all modified/added but NOT deleted or unchanged
184
+ assert.deepStrictEqual(semantic_step.files, [
185
+ 'refs/auth.md', 'refs/deploy.md', 'SKILL.md',
186
+ 'refs/new-remote.md', 'refs/new-local.md'
187
+ ])
188
+ })
189
+
190
+ it('excludes semantic_review when only deletions exist', () => {
191
+ const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
192
+ remote_only_deleted: [{ path: 'old.md', base_sha: '1' }]
193
+ }))
194
+
195
+ const steps = build_resolution_steps(report)
196
+ assert.ok(!steps.find(s => s.action === 'semantic_review'))
197
+ assert.strictEqual(steps.length, 1) // only verify
198
+ })
199
+
200
+ it('steps are ordered: markers → json → semantic → verify', () => {
201
+ const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
202
+ both_modified: [{ path: 'SKILL.md', base_sha: '1', local_sha: '2', remote_sha: '3' }],
203
+ remote_only_modified: [{ path: 'auth.md', base_sha: '4', remote_sha: '5' }]
204
+ }))
205
+ report.files.find(f => f.path === 'SKILL.md').conflict_written = true
206
+
207
+ const json_conflicts = [{ field: 'version', suggestion: '2.0.1' }]
208
+ const steps = build_resolution_steps(report, json_conflicts)
209
+
210
+ const actions = steps.map(s => s.action)
211
+ assert.deepStrictEqual(actions, [
212
+ 'resolve_conflict_markers',
213
+ 'review_json_suggestions',
214
+ 'semantic_review',
215
+ 'verify'
216
+ ])
217
+ })
218
+
219
+ it('handles empty report gracefully', () => {
220
+ const report = build_report('test/skill', '1.0.0', '1.0.0', make_classified())
221
+ const steps = build_resolution_steps(report)
222
+ assert.strictEqual(steps.length, 1)
223
+ assert.strictEqual(steps[0].action, 'verify')
224
+ })
225
+
226
+ it('suggestion reason for generic scalar field', () => {
227
+ const report = build_report('test/skill', '1.0.0', '2.0.0', make_classified({
228
+ both_modified: [{ path: 'skill.json', base_sha: '1', local_sha: '2', remote_sha: '3' }]
229
+ }))
230
+ const json_conflicts = [{ field: 'description', suggestion: 'Remote desc' }]
231
+ const steps = build_resolution_steps(report, json_conflicts)
232
+ const json_step = steps.find(s => s.action === 'review_json_suggestions')
233
+ assert.strictEqual(json_step.suggestions[0].reason, 'Remote value used as default')
234
+ })
235
+ })