happyskills 0.18.1 → 0.20.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 +17 -0
- package/package.json +2 -1
- package/src/commands/publish.js +4 -1
- package/src/commands/pull.js +156 -20
- package/src/commands/status.js +12 -8
- package/src/commands/validate.js +4 -1
- package/src/merge/changelog_merge.js +117 -0
- package/src/merge/changelog_merge.test.js +92 -0
- package/src/merge/json_merge.js +152 -0
- package/src/merge/json_merge.test.js +148 -0
- package/src/merge/report.js +86 -1
- package/src/merge/report.test.js +181 -31
- package/src/merge/text_merge.js +58 -0
- package/src/merge/text_merge.test.js +100 -0
- package/src/validation/conflict_marker_rules.js +54 -0
- package/src/validation/conflict_marker_rules.test.js +90 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.20.0] - 2026-03-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- 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)
|
|
14
|
+
- 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
|
|
15
|
+
|
|
16
|
+
## [0.19.0] - 2026-03-29
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- Add three-way auto-merge to `pull` — when both local and remote have changes and no `--theirs`/`--ours` flag is set, `pull` now auto-merges using `node-diff3` for text files, structured JSON merge for `skill.json` (always valid JSON, no conflict markers), and section-aware merge for `CHANGELOG.md` (preserves remote history, prepends local sections)
|
|
20
|
+
- Add `conflict_files` tracking in lock file — `pull` writes an array of files with unresolved conflict markers to the lock entry; `status` shows `conflicts` state when present
|
|
21
|
+
- Add conflict marker validation (`validate_no_conflict_markers`) to `validate` and `publish` — scans all skill files for `<<<<<<< LOCAL` markers and blocks publishing with errors. `--force` does NOT bypass this check (it only bypasses divergence)
|
|
22
|
+
- Add `conflicts` status to `status` command for skills with unresolved merge conflicts
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- `pull` without `--theirs`/`--ours` no longer exits with an error on `both_modified` files — it attempts auto-merge and reports any remaining conflicts with marker locations
|
|
26
|
+
|
|
10
27
|
## [0.18.1] - 2026-03-29
|
|
11
28
|
|
|
12
29
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "happyskills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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)",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"node": ">=22.0.0"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
+
"node-diff3": "^3.2.0",
|
|
46
47
|
"puffy-core": "^1.3.1",
|
|
47
48
|
"semver": "^7.6.0"
|
|
48
49
|
},
|
package/src/commands/publish.js
CHANGED
|
@@ -16,6 +16,7 @@ const { hash_directory } = require('../lock/integrity')
|
|
|
16
16
|
const { validate_skill_md } = require('../validation/skill_md_rules')
|
|
17
17
|
const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
18
18
|
const { validate_cross } = require('../validation/cross_rules')
|
|
19
|
+
const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
|
|
19
20
|
const { create_spinner } = require('../ui/spinner')
|
|
20
21
|
const { print_help, print_success, print_error, print_warn, print_hint, print_json, code } = require('../ui/output')
|
|
21
22
|
const { exit_with_error, UsageError, CliError } = require('../utils/errors')
|
|
@@ -95,8 +96,10 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
95
96
|
if (json_err) throw json_err
|
|
96
97
|
const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content, skill_type)
|
|
97
98
|
if (cross_err) throw cross_err
|
|
99
|
+
const [marker_err, marker_results] = await validate_no_conflict_markers(dir)
|
|
100
|
+
if (marker_err) throw marker_err
|
|
98
101
|
|
|
99
|
-
const all_results = [...md_data.results, ...json_data.results, ...cross_results]
|
|
102
|
+
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results]
|
|
100
103
|
const validation_errors = all_results.filter(r => r.severity === 'error')
|
|
101
104
|
const validation_warnings = all_results.filter(r => r.severity === 'warning')
|
|
102
105
|
|
package/src/commands/pull.js
CHANGED
|
@@ -5,7 +5,10 @@ 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
|
+
const { three_way_merge } = require('../merge/text_merge')
|
|
10
|
+
const { merge_skill_json } = require('../merge/json_merge')
|
|
11
|
+
const { merge_changelog } = require('../merge/changelog_merge')
|
|
9
12
|
const { hash_blob } = require('../utils/git_hash')
|
|
10
13
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
11
14
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
@@ -30,14 +33,17 @@ Options:
|
|
|
30
33
|
--force Discard all local changes, take remote entirely
|
|
31
34
|
-g, --global Pull globally installed skill
|
|
32
35
|
--json Output as JSON
|
|
36
|
+
--full-report Include inline file content and resolution steps in JSON output (for AI agent review)
|
|
33
37
|
|
|
34
38
|
Examples:
|
|
35
39
|
happyskills pull acme/deploy-aws
|
|
36
40
|
happyskills pull acme/deploy-aws --theirs
|
|
37
|
-
happyskills pull acme/deploy-aws --
|
|
41
|
+
happyskills pull acme/deploy-aws --json --full-report`
|
|
38
42
|
|
|
39
43
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
40
44
|
|
|
45
|
+
const decode_b64 = (file) => file ? Buffer.from(file.content, 'base64').toString('utf-8') : undefined
|
|
46
|
+
|
|
41
47
|
const build_local_entries = (skill_dir) => catch_errors('Failed to build local entries', async () => {
|
|
42
48
|
const entries = []
|
|
43
49
|
const walk = async (dir, prefix) => {
|
|
@@ -100,6 +106,7 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
100
106
|
|
|
101
107
|
const is_global = args.flags.global || false
|
|
102
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'])
|
|
103
110
|
const project_root = find_project_root()
|
|
104
111
|
|
|
105
112
|
// 1. Read lock file
|
|
@@ -205,26 +212,86 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
205
212
|
const classified = classify_changes(base_files, local_files, remote_files)
|
|
206
213
|
const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
|
|
207
214
|
|
|
208
|
-
//
|
|
215
|
+
// Apply changes
|
|
216
|
+
spinner.update('Applying changes...')
|
|
217
|
+
const base_file_map = new Map((base_clone.files || []).map(f => [f.path, f]))
|
|
218
|
+
const remote_file_map = new Map((remote_clone.files || []).map(f => [f.path, f]))
|
|
219
|
+
|
|
220
|
+
// Auto-merge both_modified files when no strategy is set
|
|
221
|
+
const conflict_files = []
|
|
222
|
+
const json_conflicts = []
|
|
209
223
|
if (classified.both_modified.length > 0 && !strategy) {
|
|
210
|
-
spinner.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
224
|
+
spinner.update('Auto-merging...')
|
|
225
|
+
for (const entry of classified.both_modified) {
|
|
226
|
+
const report_file = report.files.find(f => f.path === entry.path)
|
|
227
|
+
|
|
228
|
+
// Delete-vs-modify — cannot auto-merge, keep what exists
|
|
229
|
+
if (entry.local_sha === null || entry.remote_sha === null) {
|
|
230
|
+
conflict_files.push(entry.path)
|
|
231
|
+
if (report_file) report_file.conflict_written = true
|
|
232
|
+
if (entry.local_sha === null && entry.remote_sha !== null) {
|
|
233
|
+
// Local deleted, remote modified — write remote version
|
|
234
|
+
const [af_err] = await apply_remote_file(skill_dir, owner, repo, entry)
|
|
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
|
+
})
|
|
250
|
+
}
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const base_file = base_file_map.get(entry.path)
|
|
255
|
+
const remote_file = remote_file_map.get(entry.path)
|
|
256
|
+
const base_text = base_file ? Buffer.from(base_file.content, 'base64').toString('utf-8') : ''
|
|
257
|
+
const local_text = await fs.promises.readFile(path.join(skill_dir, entry.path), 'utf-8')
|
|
258
|
+
const remote_text = remote_file ? Buffer.from(remote_file.content, 'base64').toString('utf-8') : ''
|
|
259
|
+
|
|
260
|
+
if (entry.path === 'skill.json') {
|
|
261
|
+
const result = merge_skill_json(
|
|
262
|
+
JSON.parse(base_text || '{}'),
|
|
263
|
+
JSON.parse(local_text || '{}'),
|
|
264
|
+
JSON.parse(remote_text || '{}')
|
|
265
|
+
)
|
|
266
|
+
const merged_text = JSON.stringify(result.merged, null, '\t') + '\n'
|
|
267
|
+
await fs.promises.writeFile(path.join(skill_dir, entry.path), merged_text)
|
|
268
|
+
if (result.conflicts.length > 0) json_conflicts.push(...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
|
+
}
|
|
273
|
+
} else if (entry.path.toLowerCase() === 'changelog.md') {
|
|
274
|
+
const result = merge_changelog(base_text, local_text, remote_text)
|
|
275
|
+
await fs.promises.writeFile(path.join(skill_dir, entry.path), result.merged)
|
|
276
|
+
if (result.has_conflicts) conflict_files.push(entry.path)
|
|
277
|
+
if (report_file) {
|
|
278
|
+
report_file.conflict_written = result.has_conflicts
|
|
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 })
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
const result = three_way_merge(base_text, local_text, remote_text)
|
|
284
|
+
await fs.promises.writeFile(path.join(skill_dir, entry.path), result.merged)
|
|
285
|
+
if (result.has_conflicts) conflict_files.push(entry.path)
|
|
286
|
+
if (report_file) {
|
|
287
|
+
report_file.conflict_written = result.has_conflicts
|
|
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 })
|
|
290
|
+
}
|
|
217
291
|
}
|
|
218
|
-
console.error('')
|
|
219
|
-
print_hint('Auto-merge not yet available. Use --theirs (take remote) or --ours (keep local) for all conflicting files, or manually merge and publish with --force.')
|
|
220
292
|
}
|
|
221
|
-
return process.exit(EXIT_CODES.ERROR)
|
|
222
293
|
}
|
|
223
294
|
|
|
224
|
-
// Apply changes
|
|
225
|
-
spinner.update('Applying changes...')
|
|
226
|
-
const remote_file_map = new Map((remote_clone.files || []).map(f => [f.path, f]))
|
|
227
|
-
|
|
228
295
|
// Remote-only modified/added → write remote version
|
|
229
296
|
for (const entry of [...classified.remote_only_modified, ...classified.remote_only_added]) {
|
|
230
297
|
const remote_file = remote_file_map.get(entry.path)
|
|
@@ -233,19 +300,53 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
233
300
|
const [dir_err] = await ensure_dir(path.dirname(full))
|
|
234
301
|
if (dir_err) { spinner.fail(`Failed to create dir for ${entry.path}`); throw dir_err[0] }
|
|
235
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
|
+
}
|
|
236
307
|
}
|
|
237
308
|
}
|
|
238
309
|
|
|
239
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
|
+
}
|
|
240
317
|
const [del_err] = await remove_files(skill_dir, classified.remote_only_deleted.map(f => f.path))
|
|
241
318
|
if (del_err) { spinner.fail('Failed to remove deleted files'); throw del_err[0] }
|
|
242
319
|
|
|
243
320
|
// Local-only modified/added → keep (no action needed)
|
|
244
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
|
+
}
|
|
245
335
|
|
|
246
336
|
// Both-modified → apply strategy
|
|
247
337
|
if (strategy === 'theirs') {
|
|
248
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
|
+
}
|
|
249
350
|
if (entry.remote_sha === null) {
|
|
250
351
|
// Remote deleted — remove local
|
|
251
352
|
const full = path.join(skill_dir, entry.path)
|
|
@@ -261,7 +362,18 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
261
362
|
}
|
|
262
363
|
}
|
|
263
364
|
}
|
|
264
|
-
|
|
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
|
+
}
|
|
265
377
|
|
|
266
378
|
// Update lock — use head_commit from compare (authoritative, not cached)
|
|
267
379
|
const [hash_err, new_integrity] = await hash_directory(skill_dir)
|
|
@@ -274,6 +386,11 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
274
386
|
version: cmp_data.head_version || lock_entry.version,
|
|
275
387
|
ref: remote_clone.ref || lock_entry.ref
|
|
276
388
|
}
|
|
389
|
+
if (conflict_files.length > 0) {
|
|
390
|
+
updated_entry.conflict_files = conflict_files
|
|
391
|
+
} else {
|
|
392
|
+
delete updated_entry.conflict_files
|
|
393
|
+
}
|
|
277
394
|
const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
|
|
278
395
|
const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
|
|
279
396
|
if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
|
|
@@ -281,15 +398,34 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
281
398
|
spinner.stop()
|
|
282
399
|
|
|
283
400
|
if (args.flags.json) {
|
|
284
|
-
|
|
401
|
+
const status = conflict_files.length > 0 ? 'conflicts' : 'merged'
|
|
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 })
|
|
285
405
|
} else {
|
|
286
406
|
print_success(`Pulled ${skill_name} → ${cmp_data.head_version || 'latest'}`)
|
|
287
407
|
if (report.summary.auto_merged > 0) {
|
|
288
408
|
print_info(`${report.summary.auto_merged} file(s) auto-applied (non-conflicting changes)`)
|
|
289
409
|
}
|
|
290
|
-
if (classified.both_modified.length > 0) {
|
|
410
|
+
if (classified.both_modified.length > 0 && strategy) {
|
|
291
411
|
print_info(`${classified.both_modified.length} conflict(s) resolved with --${strategy}`)
|
|
292
412
|
}
|
|
413
|
+
const auto_merged_count = classified.both_modified.length - conflict_files.length
|
|
414
|
+
if (auto_merged_count > 0 && !strategy) {
|
|
415
|
+
print_info(`${auto_merged_count} file(s) auto-merged`)
|
|
416
|
+
}
|
|
417
|
+
if (json_conflicts.length > 0) {
|
|
418
|
+
print_warn(`skill.json merge suggestions (review before publishing):`)
|
|
419
|
+
for (const c of json_conflicts) {
|
|
420
|
+
console.error(` - ${c.field}: ${JSON.stringify(c.suggestion)}`)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (conflict_files.length > 0) {
|
|
424
|
+
print_warn(`${conflict_files.length} file(s) with conflict markers — resolve before publishing:`)
|
|
425
|
+
for (const f of conflict_files) {
|
|
426
|
+
console.error(` - ${f}`)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
293
429
|
}
|
|
294
430
|
|
|
295
431
|
// 7. Dependency reconciliation
|
package/src/commands/status.js
CHANGED
|
@@ -25,7 +25,8 @@ Examples:
|
|
|
25
25
|
happyskills st acme/deploy-aws
|
|
26
26
|
happyskills status --json`
|
|
27
27
|
|
|
28
|
-
const classify = (local_modified, remote_updated) => {
|
|
28
|
+
const classify = (local_modified, remote_updated, has_conflicts) => {
|
|
29
|
+
if (has_conflicts) return 'conflicts'
|
|
29
30
|
if (local_modified && remote_updated) return 'diverged'
|
|
30
31
|
if (local_modified) return 'modified'
|
|
31
32
|
if (remote_updated) return 'outdated'
|
|
@@ -78,6 +79,7 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
78
79
|
const short_name = name.split('/')[1] || name
|
|
79
80
|
const dir = skill_install_dir(base_dir, short_name)
|
|
80
81
|
const [, det] = await detect_status(data, dir)
|
|
82
|
+
const has_conflicts = (data.conflict_files || []).length > 0
|
|
81
83
|
results.push({
|
|
82
84
|
skill: name,
|
|
83
85
|
base_version: data.version || null,
|
|
@@ -87,7 +89,8 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
87
89
|
remote_updated: false,
|
|
88
90
|
remote_version: null,
|
|
89
91
|
remote_commit: null,
|
|
90
|
-
|
|
92
|
+
conflict_files: data.conflict_files || [],
|
|
93
|
+
status: has_conflicts ? 'conflicts' : 'clean'
|
|
91
94
|
})
|
|
92
95
|
}
|
|
93
96
|
|
|
@@ -112,7 +115,7 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
112
115
|
// Classify each result
|
|
113
116
|
for (const r of results) {
|
|
114
117
|
if (r.status !== 'not_found') {
|
|
115
|
-
r.status = classify(r.local_modified, r.remote_updated)
|
|
118
|
+
r.status = classify(r.local_modified, r.remote_updated, r.conflict_files.length > 0)
|
|
116
119
|
}
|
|
117
120
|
}
|
|
118
121
|
|
|
@@ -131,11 +134,12 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
131
134
|
skill: r.skill,
|
|
132
135
|
base: r.base_version || '?',
|
|
133
136
|
remote: r.remote_version || '?',
|
|
134
|
-
status: r.status === '
|
|
135
|
-
: r.status === '
|
|
136
|
-
: r.status === '
|
|
137
|
-
: r.status === '
|
|
138
|
-
: '
|
|
137
|
+
status: r.status === 'conflicts' ? 'conflicts (unresolved merge conflicts)'
|
|
138
|
+
: r.status === 'diverged' ? 'diverged (local + remote changes)'
|
|
139
|
+
: r.status === 'modified' ? 'modified (local changes)'
|
|
140
|
+
: r.status === 'outdated' ? 'outdated (remote changes)'
|
|
141
|
+
: r.status === 'not_found' ? 'not found'
|
|
142
|
+
: 'clean'
|
|
139
143
|
}))
|
|
140
144
|
|
|
141
145
|
const w_skill = Math.max(col_skill.length, ...rows.map(r => r.skill.length))
|
package/src/commands/validate.js
CHANGED
|
@@ -3,6 +3,7 @@ const { error: { catch_errors } } = require('puffy-core')
|
|
|
3
3
|
const { validate_skill_md } = require('../validation/skill_md_rules')
|
|
4
4
|
const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
5
5
|
const { validate_cross } = require('../validation/cross_rules')
|
|
6
|
+
const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
|
|
6
7
|
const { file_exists, read_json } = require('../utils/fs')
|
|
7
8
|
const { skills_dir, find_project_root } = require('../config/paths')
|
|
8
9
|
const { print_help, print_json } = require('../ui/output')
|
|
@@ -144,8 +145,10 @@ const run = (args) => catch_errors('Validate failed', async () => {
|
|
|
144
145
|
skill_type
|
|
145
146
|
)
|
|
146
147
|
if (cross_err) throw cross_err
|
|
148
|
+
const [marker_err, marker_results] = await validate_no_conflict_markers(skill_dir)
|
|
149
|
+
if (marker_err) throw marker_err
|
|
147
150
|
|
|
148
|
-
const all_results = [...md_data.results, ...json_data.results, ...cross_results]
|
|
151
|
+
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results]
|
|
149
152
|
const type_label = is_kit ? ' [kit]' : ''
|
|
150
153
|
|
|
151
154
|
if (args.flags.json) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-way merge for CHANGELOG.md files.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* - Parse into version sections (split on ## headings)
|
|
6
|
+
* - Take all remote sections (published history is authoritative)
|
|
7
|
+
* - Prepend local unreleased/new section content
|
|
8
|
+
* - Fall back to text_merge if structure is too ambiguous
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { three_way_merge } = require('./text_merge')
|
|
12
|
+
|
|
13
|
+
const VERSION_HEADING_RE = /^##\s+\[/m
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a changelog into header (preamble before first version section) and version sections.
|
|
17
|
+
* Each section starts with a `## [` heading line.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} text
|
|
20
|
+
* @returns {{ header: string, sections: string[] } | null} null if structure is ambiguous
|
|
21
|
+
*/
|
|
22
|
+
const parse_sections = (text) => {
|
|
23
|
+
if (!text) return null
|
|
24
|
+
|
|
25
|
+
const lines = text.split('\n')
|
|
26
|
+
let header_end = -1
|
|
27
|
+
const section_starts = []
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
if (VERSION_HEADING_RE.test(lines[i])) {
|
|
31
|
+
if (header_end === -1) header_end = i
|
|
32
|
+
section_starts.push(i)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Ambiguous — no version headings found
|
|
37
|
+
if (section_starts.length === 0) return null
|
|
38
|
+
|
|
39
|
+
const header = lines.slice(0, header_end).join('\n')
|
|
40
|
+
const sections = []
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < section_starts.length; i++) {
|
|
43
|
+
const start = section_starts[i]
|
|
44
|
+
const end = i + 1 < section_starts.length ? section_starts[i + 1] : lines.length
|
|
45
|
+
sections.push(lines.slice(start, end).join('\n'))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { header, sections }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extract the version string from a section heading.
|
|
53
|
+
* e.g. "## [1.5.0] - 2026-03-29" → "1.5.0"
|
|
54
|
+
* e.g. "## [Unreleased]" → "Unreleased"
|
|
55
|
+
*/
|
|
56
|
+
const extract_version = (section) => {
|
|
57
|
+
const match = section.match(/^##\s+\[([^\]]+)\]/)
|
|
58
|
+
return match ? match[1] : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} base_text
|
|
63
|
+
* @param {string} local_text
|
|
64
|
+
* @param {string} remote_text
|
|
65
|
+
* @returns {{ merged: string, has_conflicts: boolean, conflict_count: number, conflict_regions: Array, used_fallback: boolean }}
|
|
66
|
+
*/
|
|
67
|
+
const merge_changelog = (base_text, local_text, remote_text) => {
|
|
68
|
+
const base_parsed = parse_sections(base_text)
|
|
69
|
+
const local_parsed = parse_sections(local_text)
|
|
70
|
+
const remote_parsed = parse_sections(remote_text)
|
|
71
|
+
|
|
72
|
+
// Fall back to text merge if any side is too ambiguous to parse
|
|
73
|
+
if (!base_parsed || !local_parsed || !remote_parsed) {
|
|
74
|
+
const result = three_way_merge(base_text, local_text, remote_text)
|
|
75
|
+
return { ...result, used_fallback: true }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Collect remote version strings (published history)
|
|
79
|
+
const remote_versions = new Set(remote_parsed.sections.map(extract_version).filter(Boolean))
|
|
80
|
+
|
|
81
|
+
// Find local-only sections: sections in local that are NOT in base and NOT in remote
|
|
82
|
+
const base_versions = new Set(base_parsed.sections.map(extract_version).filter(Boolean))
|
|
83
|
+
const local_new_sections = local_parsed.sections.filter(s => {
|
|
84
|
+
const v = extract_version(s)
|
|
85
|
+
if (!v) return false
|
|
86
|
+
if (v === 'Unreleased') return true
|
|
87
|
+
return !base_versions.has(v) && !remote_versions.has(v)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Build merged changelog: remote header + local new sections + all remote sections
|
|
91
|
+
const parts = [remote_parsed.header]
|
|
92
|
+
|
|
93
|
+
// Add local new sections (unreleased or new version entries)
|
|
94
|
+
for (const section of local_new_sections) {
|
|
95
|
+
parts.push(section)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Add all remote sections (authoritative published history)
|
|
99
|
+
for (const section of remote_parsed.sections) {
|
|
100
|
+
const v = extract_version(section)
|
|
101
|
+
// Skip duplicate unreleased if we already included it from local
|
|
102
|
+
if (v === 'Unreleased' && local_new_sections.some(s => extract_version(s) === 'Unreleased')) {
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
parts.push(section)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
merged: parts.join('\n'),
|
|
110
|
+
has_conflicts: false,
|
|
111
|
+
conflict_count: 0,
|
|
112
|
+
conflict_regions: [],
|
|
113
|
+
used_fallback: false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { merge_changelog, parse_sections, extract_version }
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const { describe, it } = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const { merge_changelog, parse_sections, extract_version } = require('./changelog_merge')
|
|
4
|
+
|
|
5
|
+
describe('parse_sections', () => {
|
|
6
|
+
it('parses header and version sections', () => {
|
|
7
|
+
const text = '# Changelog\n\nPreamble\n\n## [1.0.0] - 2026-01-01\n\nFirst release\n\n## [0.9.0] - 2025-12-01\n\nBeta'
|
|
8
|
+
const r = parse_sections(text)
|
|
9
|
+
assert.strictEqual(r.sections.length, 2)
|
|
10
|
+
assert.ok(r.header.includes('Preamble'))
|
|
11
|
+
assert.ok(r.sections[0].includes('[1.0.0]'))
|
|
12
|
+
assert.ok(r.sections[1].includes('[0.9.0]'))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns null for text without version headings', () => {
|
|
16
|
+
assert.strictEqual(parse_sections('just some text'), null)
|
|
17
|
+
assert.strictEqual(parse_sections(null), null)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('handles Unreleased section', () => {
|
|
21
|
+
const text = '# Changelog\n\n## [Unreleased]\n\nWIP\n\n## [1.0.0]\n\nDone'
|
|
22
|
+
const r = parse_sections(text)
|
|
23
|
+
assert.strictEqual(r.sections.length, 2)
|
|
24
|
+
assert.ok(r.sections[0].includes('Unreleased'))
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('extract_version', () => {
|
|
29
|
+
it('extracts version from heading', () => {
|
|
30
|
+
assert.strictEqual(extract_version('## [1.5.0] - 2026-03-29'), '1.5.0')
|
|
31
|
+
assert.strictEqual(extract_version('## [Unreleased]'), 'Unreleased')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns null for non-heading text', () => {
|
|
35
|
+
assert.strictEqual(extract_version('some text'), null)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('merge_changelog', () => {
|
|
40
|
+
const header = '# Changelog\n\nAll notable changes.\n'
|
|
41
|
+
|
|
42
|
+
it('preserves remote history and prepends local new sections', () => {
|
|
43
|
+
const base = `${header}\n## [1.0.0]\n\nBase release`
|
|
44
|
+
const local = `${header}\n## [Unreleased]\n\nLocal work\n\n## [1.0.0]\n\nBase release`
|
|
45
|
+
const remote = `${header}\n## [1.1.0]\n\nRemote release\n\n## [1.0.0]\n\nBase release`
|
|
46
|
+
const r = merge_changelog(base, local, remote)
|
|
47
|
+
assert.strictEqual(r.has_conflicts, false)
|
|
48
|
+
assert.strictEqual(r.used_fallback, false)
|
|
49
|
+
// Local unreleased should appear before remote versions
|
|
50
|
+
const unreleased_pos = r.merged.indexOf('[Unreleased]')
|
|
51
|
+
const v11_pos = r.merged.indexOf('[1.1.0]')
|
|
52
|
+
const v10_pos = r.merged.indexOf('[1.0.0]')
|
|
53
|
+
assert.ok(unreleased_pos < v11_pos, 'Unreleased before 1.1.0')
|
|
54
|
+
assert.ok(v11_pos < v10_pos, '1.1.0 before 1.0.0')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('avoids duplicate Unreleased sections', () => {
|
|
58
|
+
const base = `${header}\n## [1.0.0]\n\nRelease`
|
|
59
|
+
const local = `${header}\n## [Unreleased]\n\nLocal\n\n## [1.0.0]\n\nRelease`
|
|
60
|
+
const remote = `${header}\n## [Unreleased]\n\nRemote\n\n## [1.0.0]\n\nRelease`
|
|
61
|
+
const r = merge_changelog(base, local, remote)
|
|
62
|
+
const count = (r.merged.match(/\[Unreleased\]/g) || []).length
|
|
63
|
+
assert.strictEqual(count, 1, 'Should have exactly one Unreleased section')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('falls back to text_merge for ambiguous structure', () => {
|
|
67
|
+
const base = 'just text'
|
|
68
|
+
const local = 'local text'
|
|
69
|
+
const remote = 'remote text'
|
|
70
|
+
const r = merge_changelog(base, local, remote)
|
|
71
|
+
assert.strictEqual(r.used_fallback, true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('handles no local changes (remote-only update)', () => {
|
|
75
|
+
const base = `${header}\n## [1.0.0]\n\nRelease`
|
|
76
|
+
const local = base
|
|
77
|
+
const remote = `${header}\n## [1.1.0]\n\nNew\n\n## [1.0.0]\n\nRelease`
|
|
78
|
+
const r = merge_changelog(base, local, remote)
|
|
79
|
+
assert.strictEqual(r.has_conflicts, false)
|
|
80
|
+
assert.ok(r.merged.includes('[1.1.0]'))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('preserves all remote sections as authoritative', () => {
|
|
84
|
+
const base = `${header}\n## [1.0.0]\n\nOriginal`
|
|
85
|
+
const local = `${header}\n## [1.0.0]\n\nOriginal`
|
|
86
|
+
const remote = `${header}\n## [1.2.0]\n\nTwo\n\n## [1.1.0]\n\nOne\n\n## [1.0.0]\n\nOriginal`
|
|
87
|
+
const r = merge_changelog(base, local, remote)
|
|
88
|
+
assert.ok(r.merged.includes('[1.2.0]'))
|
|
89
|
+
assert.ok(r.merged.includes('[1.1.0]'))
|
|
90
|
+
assert.ok(r.merged.includes('[1.0.0]'))
|
|
91
|
+
})
|
|
92
|
+
})
|