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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Structured three-way merge for skill.json files.
3
+ *
4
+ * Always produces valid JSON — no conflict markers.
5
+ * Conflicts are reported structurally so the CLI can display them
6
+ * and the user can resolve before publishing.
7
+ */
8
+
9
+ const semver = require('semver')
10
+
11
+ /**
12
+ * @param {object} base - Base skill.json (from base_commit)
13
+ * @param {object} local - Local skill.json (from disk)
14
+ * @param {object} remote - Remote skill.json (from head_commit)
15
+ * @returns {{ merged: object, conflicts: Array<{ field: string, base_value: *, local_value: *, remote_value: *, suggestion: * }> }}
16
+ */
17
+ const merge_skill_json = (base, local, remote) => {
18
+ const merged = {}
19
+ const conflicts = []
20
+
21
+ const all_keys = new Set([
22
+ ...Object.keys(base || {}),
23
+ ...Object.keys(local || {}),
24
+ ...Object.keys(remote || {})
25
+ ])
26
+
27
+ for (const key of all_keys) {
28
+ const b = base?.[key]
29
+ const l = local?.[key]
30
+ const r = remote?.[key]
31
+
32
+ if (key === 'version') {
33
+ merge_version(b, l, r, merged, conflicts)
34
+ } else if (key === 'dependencies') {
35
+ merge_dependencies(b, l, r, merged, conflicts)
36
+ } else {
37
+ merge_scalar(key, b, l, r, merged, conflicts)
38
+ }
39
+ }
40
+
41
+ return { merged, conflicts }
42
+ }
43
+
44
+ const merge_version = (base_v, local_v, remote_v, merged, conflicts) => {
45
+ const local_changed = local_v !== base_v
46
+ const remote_changed = remote_v !== base_v
47
+
48
+ if (!local_changed && !remote_changed) {
49
+ merged.version = base_v
50
+ } else if (local_changed && !remote_changed) {
51
+ merged.version = local_v
52
+ } else if (!local_changed && remote_changed) {
53
+ merged.version = remote_v
54
+ } else if (local_v === remote_v) {
55
+ // Both changed to the same value — no conflict
56
+ merged.version = local_v
57
+ } else {
58
+ // Both changed differently — suggest next patch after the higher version
59
+ const higher = semver.gt(remote_v || '0.0.0', local_v || '0.0.0') ? remote_v : local_v
60
+ const suggestion = semver.inc(higher, 'patch')
61
+ merged.version = suggestion
62
+ conflicts.push({
63
+ field: 'version',
64
+ base_value: base_v,
65
+ local_value: local_v,
66
+ remote_value: remote_v,
67
+ suggestion
68
+ })
69
+ }
70
+ }
71
+
72
+ const merge_dependencies = (base_deps, local_deps, remote_deps, merged, conflicts) => {
73
+ const b = base_deps || {}
74
+ const l = local_deps || {}
75
+ const r = remote_deps || {}
76
+ const all_dep_keys = new Set([...Object.keys(b), ...Object.keys(l), ...Object.keys(r)])
77
+ const result = {}
78
+
79
+ for (const dep of all_dep_keys) {
80
+ const bv = b[dep]
81
+ const lv = l[dep]
82
+ const rv = r[dep]
83
+ const local_changed = lv !== bv
84
+ const remote_changed = rv !== bv
85
+
86
+ if (!local_changed && !remote_changed) {
87
+ if (bv !== undefined) result[dep] = bv
88
+ } else if (local_changed && !remote_changed) {
89
+ if (lv !== undefined) result[dep] = lv // local added or modified
90
+ // lv undefined = local removed
91
+ } else if (!local_changed && remote_changed) {
92
+ if (rv !== undefined) result[dep] = rv // remote added or modified
93
+ // rv undefined = remote removed
94
+ } else {
95
+ // Both changed
96
+ if (lv === rv) {
97
+ // Same change — no conflict
98
+ if (lv !== undefined) result[dep] = lv
99
+ } else {
100
+ // Conflict — suggest remote constraint (published version wins)
101
+ const suggestion = rv !== undefined ? rv : lv
102
+ result[dep] = suggestion
103
+ conflicts.push({
104
+ field: `dependencies.${dep}`,
105
+ base_value: bv || null,
106
+ local_value: lv || null,
107
+ remote_value: rv || null,
108
+ suggestion
109
+ })
110
+ }
111
+ }
112
+ }
113
+
114
+ if (Object.keys(result).length > 0) {
115
+ merged.dependencies = result
116
+ }
117
+ }
118
+
119
+ const merge_scalar = (key, base_val, local_val, remote_val, merged, conflicts) => {
120
+ const b_json = JSON.stringify(base_val)
121
+ const l_json = JSON.stringify(local_val)
122
+ const r_json = JSON.stringify(remote_val)
123
+
124
+ const local_changed = l_json !== b_json
125
+ const remote_changed = r_json !== b_json
126
+
127
+ if (!local_changed && !remote_changed) {
128
+ if (base_val !== undefined) merged[key] = base_val
129
+ } else if (local_changed && !remote_changed) {
130
+ if (local_val !== undefined) merged[key] = local_val
131
+ } else if (!local_changed && remote_changed) {
132
+ if (remote_val !== undefined) merged[key] = remote_val
133
+ } else {
134
+ // Both changed
135
+ if (l_json === r_json) {
136
+ if (local_val !== undefined) merged[key] = local_val
137
+ } else {
138
+ // Conflict — suggest remote value
139
+ const suggestion = remote_val !== undefined ? remote_val : local_val
140
+ if (suggestion !== undefined) merged[key] = suggestion
141
+ conflicts.push({
142
+ field: key,
143
+ base_value: base_val !== undefined ? base_val : null,
144
+ local_value: local_val !== undefined ? local_val : null,
145
+ remote_value: remote_val !== undefined ? remote_val : null,
146
+ suggestion: suggestion !== undefined ? suggestion : null
147
+ })
148
+ }
149
+ }
150
+ }
151
+
152
+ module.exports = { merge_skill_json }
@@ -0,0 +1,148 @@
1
+ const { describe, it } = require('node:test')
2
+ const assert = require('node:assert/strict')
3
+ const { merge_skill_json } = require('./json_merge')
4
+
5
+ describe('merge_skill_json', () => {
6
+ it('returns base unchanged when no changes', () => {
7
+ const base = { name: 'acme/deploy', version: '1.0.0' }
8
+ const r = merge_skill_json(base, { ...base }, { ...base })
9
+ assert.deepStrictEqual(r.merged, base)
10
+ assert.strictEqual(r.conflicts.length, 0)
11
+ })
12
+
13
+ it('takes local-only scalar change', () => {
14
+ const base = { name: 'acme/deploy', version: '1.0.0', description: 'old' }
15
+ const local = { ...base, description: 'new local' }
16
+ const r = merge_skill_json(base, local, { ...base })
17
+ assert.strictEqual(r.merged.description, 'new local')
18
+ assert.strictEqual(r.conflicts.length, 0)
19
+ })
20
+
21
+ it('takes remote-only scalar change', () => {
22
+ const base = { name: 'acme/deploy', version: '1.0.0', description: 'old' }
23
+ const remote = { ...base, description: 'new remote' }
24
+ const r = merge_skill_json(base, { ...base }, remote)
25
+ assert.strictEqual(r.merged.description, 'new remote')
26
+ assert.strictEqual(r.conflicts.length, 0)
27
+ })
28
+
29
+ it('reports conflict for both-changed scalar', () => {
30
+ const base = { name: 'acme/deploy', description: 'old' }
31
+ const local = { name: 'acme/deploy', description: 'local desc' }
32
+ const remote = { name: 'acme/deploy', description: 'remote desc' }
33
+ const r = merge_skill_json(base, local, remote)
34
+ assert.strictEqual(r.conflicts.length, 1)
35
+ assert.strictEqual(r.conflicts[0].field, 'description')
36
+ assert.strictEqual(r.conflicts[0].suggestion, 'remote desc')
37
+ })
38
+
39
+ it('reports version conflict with suggested next patch', () => {
40
+ const base = { version: '1.5.0' }
41
+ const local = { version: '1.6.0' }
42
+ const remote = { version: '1.8.0' }
43
+ const r = merge_skill_json(base, local, remote)
44
+ assert.strictEqual(r.conflicts.length, 1)
45
+ assert.strictEqual(r.conflicts[0].field, 'version')
46
+ assert.strictEqual(r.merged.version, '1.8.1')
47
+ assert.strictEqual(r.conflicts[0].suggestion, '1.8.1')
48
+ })
49
+
50
+ it('takes local-only version change without conflict', () => {
51
+ const base = { version: '1.0.0' }
52
+ const local = { version: '1.1.0' }
53
+ const r = merge_skill_json(base, local, { ...base })
54
+ assert.strictEqual(r.merged.version, '1.1.0')
55
+ assert.strictEqual(r.conflicts.length, 0)
56
+ })
57
+
58
+ it('takes remote-only version change without conflict', () => {
59
+ const base = { version: '1.0.0' }
60
+ const remote = { version: '1.2.0' }
61
+ const r = merge_skill_json(base, { ...base }, remote)
62
+ assert.strictEqual(r.merged.version, '1.2.0')
63
+ assert.strictEqual(r.conflicts.length, 0)
64
+ })
65
+
66
+ it('merges dependencies — local-only add', () => {
67
+ const base = { dependencies: { 'acme/utils': '^2.0.0' } }
68
+ const local = { dependencies: { 'acme/utils': '^2.0.0', 'acme/logger': '^1.0.0' } }
69
+ const r = merge_skill_json(base, local, { ...base })
70
+ assert.strictEqual(r.merged.dependencies['acme/logger'], '^1.0.0')
71
+ assert.strictEqual(r.merged.dependencies['acme/utils'], '^2.0.0')
72
+ assert.strictEqual(r.conflicts.length, 0)
73
+ })
74
+
75
+ it('merges dependencies — remote-only add', () => {
76
+ const base = { dependencies: { 'acme/utils': '^2.0.0' } }
77
+ const remote = { dependencies: { 'acme/utils': '^2.0.0', 'acme/auth': '^1.0.0' } }
78
+ const r = merge_skill_json(base, { ...base }, remote)
79
+ assert.strictEqual(r.merged.dependencies['acme/auth'], '^1.0.0')
80
+ assert.strictEqual(r.conflicts.length, 0)
81
+ })
82
+
83
+ it('merges dependencies — both add different deps (clean)', () => {
84
+ const base = { dependencies: {} }
85
+ const local = { dependencies: { 'acme/logger': '^1.0.0' } }
86
+ const remote = { dependencies: { 'acme/auth': '^1.0.0' } }
87
+ const r = merge_skill_json(base, local, remote)
88
+ assert.strictEqual(r.merged.dependencies['acme/logger'], '^1.0.0')
89
+ assert.strictEqual(r.merged.dependencies['acme/auth'], '^1.0.0')
90
+ assert.strictEqual(r.conflicts.length, 0)
91
+ })
92
+
93
+ it('reports conflict for same dep with different constraints', () => {
94
+ const base = { dependencies: { 'acme/utils': '^2.0.0' } }
95
+ const local = { dependencies: { 'acme/utils': '^2.5.0' } }
96
+ const remote = { dependencies: { 'acme/utils': '^3.0.0' } }
97
+ const r = merge_skill_json(base, local, remote)
98
+ assert.strictEqual(r.conflicts.length, 1)
99
+ assert.strictEqual(r.conflicts[0].field, 'dependencies.acme/utils')
100
+ assert.strictEqual(r.conflicts[0].suggestion, '^3.0.0')
101
+ assert.strictEqual(r.merged.dependencies['acme/utils'], '^3.0.0')
102
+ })
103
+
104
+ it('handles local dep removal', () => {
105
+ const base = { dependencies: { 'acme/utils': '^2.0.0', 'acme/logger': '^1.0.0' } }
106
+ const local = { dependencies: { 'acme/utils': '^2.0.0' } }
107
+ const r = merge_skill_json(base, local, { ...base })
108
+ assert.strictEqual(r.merged.dependencies['acme/logger'], undefined)
109
+ assert.strictEqual(r.merged.dependencies['acme/utils'], '^2.0.0')
110
+ assert.strictEqual(r.conflicts.length, 0)
111
+ })
112
+
113
+ it('handles null base (no previous skill.json)', () => {
114
+ const local = { name: 'acme/deploy', version: '1.0.0' }
115
+ const remote = { name: 'acme/deploy', version: '1.0.0' }
116
+ const r = merge_skill_json(null, local, remote)
117
+ assert.strictEqual(r.merged.name, 'acme/deploy')
118
+ assert.strictEqual(r.conflicts.length, 0)
119
+ })
120
+
121
+ it('always produces valid JSON (no conflict markers)', () => {
122
+ const base = { version: '1.0.0', name: 'a' }
123
+ const local = { version: '2.0.0', name: 'b' }
124
+ const remote = { version: '3.0.0', name: 'c' }
125
+ const r = merge_skill_json(base, local, remote)
126
+ const json_str = JSON.stringify(r.merged)
127
+ assert.doesNotThrow(() => JSON.parse(json_str))
128
+ assert.ok(!json_str.includes('<<<<<<<'))
129
+ })
130
+
131
+ it('handles the full spec example correctly', () => {
132
+ const base = { version: '1.5.0', dependencies: { 'acme/utils': '^2.0.0' } }
133
+ const local = { version: '1.6.0', dependencies: { 'acme/utils': '^2.0.0', 'acme/logger': '^1.0.0' } }
134
+ const remote = { version: '1.8.0', dependencies: { 'acme/utils': '^3.0.0', 'acme/auth': '^1.0.0' } }
135
+ const r = merge_skill_json(base, local, remote)
136
+ // Version: both changed → suggestion = 1.8.1
137
+ assert.strictEqual(r.merged.version, '1.8.1')
138
+ // Utils: remote-only change → ^3.0.0
139
+ assert.strictEqual(r.merged.dependencies['acme/utils'], '^3.0.0')
140
+ // Logger: local-only add
141
+ assert.strictEqual(r.merged.dependencies['acme/logger'], '^1.0.0')
142
+ // Auth: remote-only add
143
+ assert.strictEqual(r.merged.dependencies['acme/auth'], '^1.0.0')
144
+ // Only version should be a conflict
145
+ const version_conflicts = r.conflicts.filter(c => c.field === 'version')
146
+ assert.strictEqual(version_conflicts.length, 1)
147
+ })
148
+ })
@@ -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
+ })