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 +15 -0
- package/package.json +1 -1
- package/src/commands/check.js +18 -7
- package/src/commands/pull.js +77 -7
- package/src/commands/refresh.js +35 -8
- package/src/merge/report.js +86 -1
- package/src/merge/report.test.js +181 -31
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
package/src/commands/check.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
76
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: '
|
|
77
|
-
} else if (
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/pull.js
CHANGED
|
@@ -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 --
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/commands/refresh.js
CHANGED
|
@@ -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.
|
|
82
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: '
|
|
83
|
-
} else if (
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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).`)
|
package/src/merge/report.js
CHANGED
|
@@ -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
|
-
|
|
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 }
|
package/src/merge/report.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|