happyskills 0.47.1 → 0.49.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.
@@ -16,6 +16,9 @@ const {
16
16
  determine_next_step,
17
17
  build_retry_envelope,
18
18
  parse_input,
19
+ resolve_row_name,
20
+ normalize_data_rows,
21
+ extract_data_array_from_search_output,
19
22
  } = require('./postlex')
20
23
 
21
24
  // ─── Test fixtures ────────────────────────────────────────────────────────
@@ -283,6 +286,144 @@ describe('parse_input', () => {
283
286
 
284
287
  // ─── build_final_ordering ─────────────────────────────────────────────────
285
288
 
289
+ // ─── resolve_row_name + normalize_data_rows (v0.48.0) ─────────────────────
290
+
291
+ describe('resolve_row_name', () => {
292
+ it('returns row.name when present', () => {
293
+ assert.equal(resolve_row_name({ name: 'foo', skill: 'acme/foo' }), 'foo')
294
+ })
295
+
296
+ it('falls back to last segment of skill when name is missing', () => {
297
+ assert.equal(resolve_row_name({ skill: 'acme/deploy-aws' }), 'deploy-aws')
298
+ })
299
+
300
+ it('returns null when neither name nor skill is present', () => {
301
+ assert.equal(resolve_row_name({ description: 'whatever' }), null)
302
+ assert.equal(resolve_row_name({}), null)
303
+ assert.equal(resolve_row_name(null), null)
304
+ })
305
+
306
+ it('returns null when skill is malformed (no slash, just a bare value, returns the value itself)', () => {
307
+ // "deploy-aws" with no slash is a one-segment slug — last segment IS deploy-aws.
308
+ assert.equal(resolve_row_name({ skill: 'deploy-aws' }), 'deploy-aws')
309
+ })
310
+
311
+ it('handles empty-string name by falling back to skill', () => {
312
+ assert.equal(resolve_row_name({ name: '', skill: 'acme/foo' }), 'foo')
313
+ })
314
+ })
315
+
316
+ describe('normalize_data_rows', () => {
317
+ it('adds name to rows that only have skill', () => {
318
+ const data = [{ skill: 'acme/deploy-aws', workspace_slug: 'acme' }]
319
+ normalize_data_rows(data)
320
+ assert.equal(data[0].name, 'deploy-aws')
321
+ })
322
+
323
+ it('leaves rows with existing name untouched', () => {
324
+ const data = [{ name: 'pre-existing', skill: 'acme/different' }]
325
+ normalize_data_rows(data)
326
+ assert.equal(data[0].name, 'pre-existing')
327
+ })
328
+
329
+ it('is idempotent', () => {
330
+ const data = [{ skill: 'acme/deploy-aws' }]
331
+ normalize_data_rows(data)
332
+ normalize_data_rows(data)
333
+ assert.equal(data[0].name, 'deploy-aws')
334
+ })
335
+
336
+ it('handles non-array input without crashing', () => {
337
+ normalize_data_rows(null)
338
+ normalize_data_rows('not an array')
339
+ normalize_data_rows({})
340
+ // no assertion — just confirming no throw
341
+ })
342
+ })
343
+
344
+ // ─── extract_data_array_from_search_output (v0.48.0) ──────────────────────
345
+
346
+ describe('extract_data_array_from_search_output', () => {
347
+ it('extracts data.results from the canonical envelope shape', () => {
348
+ const env = { data: { query: 'q', mode: 'semantic', results: [{ name: 'foo' }] }, error: null, next_step: null }
349
+ const r = extract_data_array_from_search_output(env)
350
+ assert.equal(r.length, 1)
351
+ assert.equal(r[0].name, 'foo')
352
+ })
353
+
354
+ it('handles legacy {data: [...]} (data is bare array)', () => {
355
+ const env = { data: [{ name: 'foo' }] }
356
+ assert.deepEqual(extract_data_array_from_search_output(env), [{ name: 'foo' }])
357
+ })
358
+
359
+ it('handles bare array', () => {
360
+ const env = [{ name: 'foo' }]
361
+ assert.deepEqual(extract_data_array_from_search_output(env), env)
362
+ })
363
+
364
+ it('handles double-wrapped {data: {data: [...]}} (defensive)', () => {
365
+ const env = { data: { data: [{ name: 'foo' }] } }
366
+ assert.deepEqual(extract_data_array_from_search_output(env), [{ name: 'foo' }])
367
+ })
368
+
369
+ it('returns null when no data array can be found', () => {
370
+ assert.equal(extract_data_array_from_search_output({}), null)
371
+ assert.equal(extract_data_array_from_search_output({ data: null }), null)
372
+ assert.equal(extract_data_array_from_search_output('string'), null)
373
+ assert.equal(extract_data_array_from_search_output(null), null)
374
+ })
375
+ })
376
+
377
+ // ─── parse_input with --search-output path (v0.48.0) ──────────────────────
378
+
379
+ describe('parse_input with --search-output', () => {
380
+ it('extracts data.results from a full search envelope passed as the third argument', () => {
381
+ const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'top' }] })
382
+ const search_out = JSON.stringify({
383
+ data: { query: 'q', mode: 'semantic', results: [{ name: 'deploy-aws', workspace_slug: 'acme' }] },
384
+ error: null,
385
+ next_step: null,
386
+ })
387
+ const r = parse_input(ranking, null, search_out)
388
+ assert.equal(r.parse_error, null)
389
+ assert.equal(r.ranking[0].candidate_id, 1)
390
+ assert.equal(r.data[0].name, 'deploy-aws')
391
+ })
392
+
393
+ it('search-output overrides inline data when both are provided', () => {
394
+ const combined = JSON.stringify({
395
+ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }],
396
+ data: [{ name: 'stale-inline-name' }],
397
+ })
398
+ const search_out = JSON.stringify({ data: { results: [{ name: 'fresh-from-search-output' }] } })
399
+ const r = parse_input(combined, null, search_out)
400
+ assert.equal(r.parse_error, null)
401
+ assert.equal(r.data[0].name, 'fresh-from-search-output')
402
+ })
403
+
404
+ it('normalizes rows that only have skill (no name) when sourced from search-output', () => {
405
+ const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
406
+ // to_smart_json before v0.48.0 emitted `skill` without `name`. Simulate that.
407
+ const search_out = JSON.stringify({ data: { results: [{ skill: 'acme/legacy-row', workspace_slug: 'acme' }] } })
408
+ const r = parse_input(ranking, null, search_out)
409
+ assert.equal(r.parse_error, null)
410
+ assert.equal(r.data[0].name, 'legacy-row')
411
+ })
412
+
413
+ it('errors when search-output does not contain a data array', () => {
414
+ const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
415
+ const search_out = JSON.stringify({ data: { query: 'q' } })
416
+ const r = parse_input(ranking, null, search_out)
417
+ assert.match(r.parse_error, /does not contain a data\.results array/)
418
+ })
419
+
420
+ it('errors with an actionable message when data is missing from all sources', () => {
421
+ const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
422
+ const r = parse_input(ranking, null, null)
423
+ assert.match(r.parse_error, /--search-output/)
424
+ })
425
+ })
426
+
286
427
  describe('build_final_ordering', () => {
287
428
  it('joins ranking with data rows, producing slug + rationale', () => {
288
429
  const data = make_data(['deploy-aws', 'serverless'])
@@ -260,45 +260,52 @@ const run = (args) => catch_errors('Publish failed', async () => {
260
260
 
261
261
  spinner.succeed(`Published ${workspace.slug}/${manifest.name}@${manifest.version}`)
262
262
 
263
- // Update lock file: set base_commit and base_integrity to new values
263
+ // Update lock file: set base_commit and base_integrity to new values.
264
+ // When no lock file exists yet (first publish in a fresh project), we
265
+ // still create one — otherwise downstream `list`/`status`/`check` would
266
+ // treat the just-published skill as external. This was a latent bug
267
+ // pre-260523-02; the spec's lock-as-registry-view principle (§ 4.5) makes
268
+ // it especially important to keep the lock authoritative.
264
269
  const full_name = full_name_pre
265
270
  let post_publish_entry = null
266
- if (!lock_err && lock_data) {
267
- const all_skills = get_all_locked_skills(lock_data)
268
- const suffix = `/${skill_name}`
269
- const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
270
- const [hash_err, integrity] = await hash_directory(dir)
271
- if (lock_key && all_skills[lock_key]) {
272
- const updated_entry = {
273
- ...all_skills[lock_key],
274
- version: manifest.version,
275
- ref: push_data?.ref || `refs/tags/v${manifest.version}`,
276
- commit: push_data?.commit || null,
277
- base_commit: push_data?.commit || null,
278
- base_integrity: (!hash_err && integrity) ? integrity : null,
279
- dependencies: manifest.dependencies || {}
280
- }
281
- if (!hash_err && integrity) updated_entry.integrity = integrity
282
- delete updated_entry.merge_parents
283
- delete updated_entry.conflict_files
284
- const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
285
- await write_lock(project_root, updated_skills)
286
- post_publish_entry = updated_entry
287
- } else {
288
- const new_entry = {
289
- version: manifest.version,
290
- ref: push_data?.ref || `refs/tags/v${manifest.version}`,
291
- commit: push_data?.commit || null,
292
- integrity: (!hash_err && integrity) ? integrity : null,
293
- base_commit: push_data?.commit || null,
294
- base_integrity: (!hash_err && integrity) ? integrity : null,
295
- requested_by: ['__root__'],
296
- dependencies: manifest.dependencies || {}
297
- }
298
- const updated_skills = update_lock_skills(lock_data, { [full_name]: new_entry })
299
- await write_lock(project_root, updated_skills)
300
- post_publish_entry = new_entry
271
+ const [hash_err, integrity] = await hash_directory(dir)
272
+
273
+ const new_entry = {
274
+ version: manifest.version,
275
+ ref: push_data?.ref || `refs/tags/v${manifest.version}`,
276
+ commit: push_data?.commit || null,
277
+ integrity: (!hash_err && integrity) ? integrity : null,
278
+ base_commit: push_data?.commit || null,
279
+ base_integrity: (!hash_err && integrity) ? integrity : null,
280
+ requested_by: ['__root__'],
281
+ dependencies: manifest.dependencies || {}
282
+ }
283
+
284
+ const lock_data_to_use = (!lock_err && lock_data) ? lock_data : { lockVersion: 2, generatedAt: new Date().toISOString(), skills: {} }
285
+ const all_skills = get_all_locked_skills(lock_data_to_use)
286
+ const suffix = `/${skill_name}`
287
+ const existing_lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
288
+
289
+ if (existing_lock_key && all_skills[existing_lock_key]) {
290
+ const updated_entry = {
291
+ ...all_skills[existing_lock_key],
292
+ version: manifest.version,
293
+ ref: push_data?.ref || `refs/tags/v${manifest.version}`,
294
+ commit: push_data?.commit || null,
295
+ base_commit: push_data?.commit || null,
296
+ base_integrity: (!hash_err && integrity) ? integrity : null,
297
+ dependencies: manifest.dependencies || {}
301
298
  }
299
+ if (!hash_err && integrity) updated_entry.integrity = integrity
300
+ delete updated_entry.merge_parents
301
+ delete updated_entry.conflict_files
302
+ const updated_skills = update_lock_skills(lock_data_to_use, { [existing_lock_key]: updated_entry })
303
+ await write_lock(project_root, updated_skills)
304
+ post_publish_entry = updated_entry
305
+ } else {
306
+ const updated_skills = update_lock_skills(lock_data_to_use, { [full_name]: new_entry })
307
+ await write_lock(project_root, updated_skills)
308
+ post_publish_entry = new_entry
302
309
  }
303
310
 
304
311
  // Post-write verification — confirm the lock entry now agrees with the
@@ -33,6 +33,7 @@ Options:
33
33
  --theirs [files] Take remote version on conflicts (all, or comma-separated file list)
34
34
  --ours [files] Keep local version on conflicts (all, or comma-separated file list)
35
35
  --force Discard all local changes, take remote entirely
36
+ --rebase Rebase local edits onto the remote head (snapshot-backed, structured rejection envelope on failure)
36
37
  -g, --global Pull globally installed skill
37
38
  --strict Fail on incompatible dependency ranges instead of warning
38
39
  --json Output as JSON
@@ -40,6 +41,7 @@ Options:
40
41
 
41
42
  Examples:
42
43
  happyskills pull acme/deploy-aws
44
+ happyskills pull acme/deploy-aws --rebase --json
43
45
  happyskills pull acme/deploy-aws --theirs
44
46
  happyskills pull acme/deploy-aws --theirs SKILL.md,skill.json --ours references/foo.md
45
47
  happyskills pull acme/deploy-aws --json --full-report`
@@ -111,6 +113,44 @@ const run = (args) => catch_errors('Pull failed', async () => {
111
113
  const is_global = args.flags.global || false
112
114
  const full_report = !!(args.flags.json && args.flags['full-report'])
113
115
 
116
+ // § 8.3 — rebase-style pull. Delegates to merge/rebase.js for the snapshot-
117
+ // first capture/fast-forward/reapply flow with structured rejection envelopes.
118
+ if (args.flags.rebase) {
119
+ const { rebase_pull } = require('../merge/rebase')
120
+ const [err, result] = await rebase_pull(skill_name, { project_root: find_project_root(), is_global })
121
+ if (err) throw err
122
+ if (args.flags.json) {
123
+ print_json({
124
+ data: result.data || null,
125
+ next_step: result.next_step || null,
126
+ error: result.error || null
127
+ })
128
+ if (result.next_step) process.exit(EXIT_CODES.ERROR)
129
+ return
130
+ }
131
+ if (result.error) {
132
+ print_warn(result.error.message || 'Pull --rebase failed')
133
+ if (result.next_step) print_hint(`Next: ${result.next_step.action}`)
134
+ process.exit(EXIT_CODES.ERROR)
135
+ return
136
+ }
137
+ if (result.next_step?.action === 'resolve_patch_rejections') {
138
+ print_warn(`Pull --rebase: ${result.data.patches_rejected.length} patch(es) need resolution.`)
139
+ for (const r of result.data.patches_rejected) {
140
+ print_info(` - ${r.file}: ${r.reason}`)
141
+ }
142
+ print_hint(`Restore the pre-rebase state with ${code(`happyskills snapshot restore ${result.data.snapshot_id}`)} if needed.`)
143
+ process.exit(EXIT_CODES.ERROR)
144
+ return
145
+ }
146
+ if (result.data?.status === 'up_to_date') {
147
+ print_info(`${skill_name} is already up to date.`)
148
+ return
149
+ }
150
+ print_success(`Rebased ${skill_name} → ${result.data.version || 'latest'}`)
151
+ return
152
+ }
153
+
114
154
  // Parse per-file strategies: --theirs SKILL.md,skill.json --ours references/foo.md
115
155
  const theirs_files = typeof args.flags.theirs === 'string'
116
156
  ? new Set(args.flags.theirs.split(',').map(s => s.trim()))
@@ -0,0 +1,229 @@
1
+ const path = require('path')
2
+ const fs = require('fs')
3
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
4
+ const { read_lock, get_all_locked_skills } = require('../lock/reader')
5
+ const { write_lock, update_lock_skills } = require('../lock/writer')
6
+ const { verify_lock_disk_consistency, detect_ahead_state } = require('../lock/verify')
7
+ const { write_manifest } = require('../manifest/writer')
8
+ const { read_manifest } = require('../manifest/reader')
9
+ const { find_project_root, skills_dir, skill_install_dir, lock_root } = require('../config/paths')
10
+ const { print_help, print_info, print_json, print_warn, print_success, print_hint, code } = require('../ui/output')
11
+ const { exit_with_error, UsageError } = require('../utils/errors')
12
+ const { EXIT_CODES } = require('../constants')
13
+
14
+ const HELP_TEXT = `Usage: happyskills reconcile <owner/skill> [--apply <action>] [options]
15
+
16
+ Diagnose drift and apply a deterministic repair, or emit structured options
17
+ for the operator to choose from. Reserved for GENUINE drift only —
18
+ regression, missing files, or unparseable skill.json. The "ahead" state
19
+ (disk version > lock version) is NOT drift; reconcile reports it as a
20
+ non-event and points back to publish.
21
+
22
+ Arguments:
23
+ owner/skill Skill to inspect
24
+
25
+ Options:
26
+ --apply <action> Execute one of the recommended options directly
27
+ (skips the next_step round-trip).
28
+ -g, --global Operate on globally-installed skills
29
+ --json Output as JSON
30
+
31
+ Examples:
32
+ happyskills reconcile acme/deploy-aws --json
33
+ happyskills reconcile acme/deploy-aws --apply restore_from_lock_version --json`
34
+
35
+ const SUBTYPE_OPTIONS = {
36
+ regression: ['restore_from_lock_version', 'accept_disk_as_explicit_downgrade', 'investigate_with_diff'],
37
+ missing_skill_json: ['restore_from_registry_at_lock_version', 'restore_from_git', 'abandon'],
38
+ missing_dir: ['reinstall_at_lock_version', 'abandon']
39
+ }
40
+
41
+ const ACTION_NEXT_STEPS = {
42
+ regression: 'resolve_regression',
43
+ missing_skill_json: 'resolve_missing_skill_json',
44
+ missing_dir: 'resolve_missing_dir'
45
+ }
46
+
47
+ const resolve_lock_entry = (raw, project_root, is_global) => catch_errors('Failed to resolve lock entry', async () => {
48
+ const [, lock_data] = await read_lock(lock_root(is_global, project_root))
49
+ if (!lock_data) return { full: null, lock_entry: null, lock_data: null }
50
+ const all = get_all_locked_skills(lock_data)
51
+ let full = raw
52
+ let lock_entry = all[full] || null
53
+ if (!lock_entry && !raw.includes('/')) {
54
+ const suffix = `/${raw}`
55
+ full = Object.keys(all).find(k => k.endsWith(suffix)) || raw
56
+ lock_entry = all[full] || null
57
+ }
58
+ return { full, lock_entry, lock_data }
59
+ })
60
+
61
+ const apply_restore_from_lock_version = ({ skill_dir, lock_entry }) => catch_errors('Failed to restore from lock', async () => {
62
+ const [read_err, manifest] = await read_manifest(skill_dir)
63
+ if (read_err) throw e('Failed to read skill.json', read_err)
64
+ manifest.version = lock_entry.version
65
+ const [write_err] = await write_manifest(skill_dir, manifest)
66
+ if (write_err) throw e('Failed to write skill.json', write_err)
67
+ return { applied: 'restore_from_lock_version', new_disk_version: lock_entry.version }
68
+ })
69
+
70
+ const reconcile_one = (full, lock_entry, skill_dir, options) => catch_errors('Failed to reconcile', async () => {
71
+ if (!lock_entry) {
72
+ return {
73
+ data: { skill: full, no_drift: true, status: 'not_found' },
74
+ next_step: { action: 'install_first', context: { skill: full, hint: `Skill is not in the lock file. Install it first.` } }
75
+ }
76
+ }
77
+
78
+ const [verify_err, verify] = await verify_lock_disk_consistency(lock_entry, skill_dir)
79
+ if (verify_err) throw verify_err[0] || verify_err
80
+
81
+ if (verify.ok) {
82
+ // No genuine drift. Could be clean, modified, or ahead. Reconcile only
83
+ // reports — it doesn't touch any of these.
84
+ const [, ahead] = await detect_ahead_state(lock_entry, skill_dir)
85
+ if (ahead && ahead.ahead) {
86
+ return {
87
+ data: {
88
+ skill: full,
89
+ no_drift: true,
90
+ status: 'ahead',
91
+ ahead: {
92
+ lock_version: ahead.lock_version,
93
+ disk_version: ahead.disk_version,
94
+ has_changelog_entry: ahead.has_changelog_entry || false,
95
+ changelog_version: ahead.changelog_version || null
96
+ }
97
+ },
98
+ next_step: null,
99
+ hint: `ahead is a valid precondition for publish, not drift — use \`release\` or \`publish\` to advance the lock, or \`pull\` if the registry has advanced past lock_version`
100
+ }
101
+ }
102
+ return {
103
+ data: { skill: full, no_drift: true, status: 'clean' },
104
+ next_step: null
105
+ }
106
+ }
107
+
108
+ // Genuine drift — handle by subtype.
109
+ const drift = { reason: verify.reason, lock_version: verify.expected, disk_version: verify.actual }
110
+ const apply = options.apply || null
111
+
112
+ if (verify.reason === 'regression') {
113
+ if (apply === 'restore_from_lock_version') {
114
+ const [apply_err, applied] = await apply_restore_from_lock_version({ skill_dir, lock_entry })
115
+ if (apply_err) throw e('Apply failed', apply_err)
116
+ return {
117
+ data: { skill: full, drift_state: 'regression', applied, lock_version: lock_entry.version, disk_version: applied.new_disk_version },
118
+ next_step: null
119
+ }
120
+ }
121
+ return {
122
+ data: { skill: full, drift_state: 'regression', lock_version: lock_entry.version, disk_version: drift.disk_version },
123
+ next_step: {
124
+ action: 'resolve_regression',
125
+ context: {
126
+ skill: full,
127
+ drift,
128
+ options: SUBTYPE_OPTIONS.regression,
129
+ hint: 'Choose: restore_from_lock_version (Edit skill.json back to lock), accept_disk_as_explicit_downgrade (publish disk version as a downgrade), or investigate_with_diff (run happyskills diff first).'
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (verify.reason === 'missing_skill_json') {
136
+ return {
137
+ data: { skill: full, drift_state: 'missing_skill_json', lock_version: lock_entry.version },
138
+ next_step: {
139
+ action: 'resolve_missing_skill_json',
140
+ context: {
141
+ skill: full,
142
+ drift,
143
+ options: SUBTYPE_OPTIONS.missing_skill_json,
144
+ hint: 'Choose: restore_from_git (if tracked), restore_from_registry_at_lock_version (verify lock_version is published first), or abandon.'
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ if (verify.reason === 'missing_dir') {
151
+ return {
152
+ data: { skill: full, drift_state: 'missing_dir', lock_version: lock_entry.version },
153
+ next_step: {
154
+ action: 'resolve_missing_dir',
155
+ context: {
156
+ skill: full,
157
+ drift,
158
+ options: SUBTYPE_OPTIONS.missing_dir,
159
+ hint: 'Choose: reinstall_at_lock_version (no --fresh needed; the directory is missing), or abandon (uninstall to clear the stale lock entry).'
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ return {
166
+ data: { skill: full, drift_state: verify.reason || 'unknown', drift },
167
+ next_step: {
168
+ action: 'resolve_unknown_drift',
169
+ context: { skill: full, drift, hint: 'Unrecognized drift subtype. Surface to the user for manual decision.' }
170
+ }
171
+ }
172
+ })
173
+
174
+ const run = (args) => catch_errors('Reconcile failed', async () => {
175
+ if (args.flags._show_help) {
176
+ print_help(HELP_TEXT)
177
+ return process.exit(EXIT_CODES.SUCCESS)
178
+ }
179
+
180
+ const raw = args._[0]
181
+ if (!raw) throw new UsageError('Usage: happyskills reconcile <owner/skill> [--apply <action>]')
182
+
183
+ const project_root = find_project_root()
184
+ const is_global = !!args.flags.global
185
+ const base_dir = skills_dir(is_global, project_root)
186
+ const apply = typeof args.flags.apply === 'string' ? args.flags.apply : null
187
+
188
+ const [resolve_err, resolved] = await resolve_lock_entry(raw, project_root, is_global)
189
+ if (resolve_err) throw e('Failed to resolve skill', resolve_err)
190
+
191
+ const full = resolved.full
192
+ const lock_entry = resolved.lock_entry
193
+ const short = full && full.includes('/') ? full.split('/')[1] : (raw.includes('/') ? raw.split('/')[1] : raw)
194
+ const skill_dir = skill_install_dir(base_dir, short)
195
+
196
+ const [recon_err, result] = await reconcile_one(full, lock_entry, skill_dir, { apply })
197
+ if (recon_err) throw e('Reconcile failed', recon_err)
198
+
199
+ if (args.flags.json) {
200
+ const envelope = { data: result.data }
201
+ if (result.next_step !== undefined) envelope.next_step = result.next_step
202
+ if (result.hint !== undefined) envelope.hint = result.hint
203
+ print_json(envelope)
204
+ return
205
+ }
206
+
207
+ // Human output — terse, lead with plain meaning.
208
+ if (result.data?.no_drift) {
209
+ if (result.data.status === 'ahead') {
210
+ print_info(`${full} is ahead (lock ${result.data.ahead.lock_version}, disk ${result.data.ahead.disk_version}).`)
211
+ print_hint(`Publish with ${code(`happyskills publish ${full}`)}.`)
212
+ return
213
+ }
214
+ print_success(`${full} is ${result.data.status} — nothing to reconcile.`)
215
+ return
216
+ }
217
+ if (result.data?.applied) {
218
+ print_success(`Applied ${result.data.applied.applied} — skill.json now at ${result.data.applied.new_disk_version}.`)
219
+ return
220
+ }
221
+ if (result.next_step) {
222
+ print_warn(`${full} has drift (${result.data.drift_state || 'unknown'}).`)
223
+ const opts = result.next_step.context?.options || []
224
+ for (const o of opts) print_info(` - ${o}`)
225
+ print_hint(`Pick one and re-run: ${code(`happyskills reconcile ${full} --apply <option>`)}`)
226
+ }
227
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
228
+
229
+ module.exports = { run, reconcile_one, SUBTYPE_OPTIONS, ACTION_NEXT_STEPS }