happyskills 0.54.0 → 1.0.1

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/package.json +1 -1
  3. package/src/api/auth.js +18 -2
  4. package/src/api/client.js +29 -3
  5. package/src/api/feedback.js +14 -5
  6. package/src/api/repos.js +28 -10
  7. package/src/api/translate.js +90 -0
  8. package/src/commands/delete.js +15 -1
  9. package/src/commands/feedback.js +4 -4
  10. package/src/commands/init.js +5 -1
  11. package/src/commands/install.js +58 -32
  12. package/src/commands/postlex.js +74 -38
  13. package/src/commands/postlex.test.js +96 -18
  14. package/src/commands/pull.js +5 -1
  15. package/src/commands/reconcile.js +52 -4
  16. package/src/commands/release.js +45 -15
  17. package/src/commands/schema.js +179 -0
  18. package/src/commands/search.js +27 -23
  19. package/src/commands/search.test.js +59 -33
  20. package/src/commands/uninstall.js +20 -11
  21. package/src/commands/validate.js +33 -11
  22. package/src/constants/error_codes.js +197 -0
  23. package/src/constants/exit_codes.js +54 -0
  24. package/src/constants/next_step_actions.js +133 -0
  25. package/src/constants/next_step_by_error_code.js +249 -0
  26. package/src/constants.js +2 -1
  27. package/src/index.js +51 -7
  28. package/src/integration/api_envelope.test.js +499 -0
  29. package/src/integration/bump.test.js +13 -4
  30. package/src/integration/cli.test.js +169 -147
  31. package/src/integration/drift.test.js +16 -4
  32. package/src/integration/install_fresh.test.js +37 -29
  33. package/src/integration/reconcile.test.js +77 -56
  34. package/src/integration/release.test.js +48 -31
  35. package/src/integration/schema.test.js +167 -0
  36. package/src/schema/envelope.schema.json +73 -0
  37. package/src/schema/envelope_test_helpers.js +94 -0
  38. package/src/schema/envelope_validator.js +239 -0
  39. package/src/schema/envelope_validator.test.js +333 -0
  40. package/src/ui/envelope.js +171 -0
  41. package/src/ui/output.js +66 -2
  42. package/src/utils/errors.js +116 -47
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Spec 260521-01 v2 § 5.2 — this command is the deterministic safety net
6
6
  // for the rerank protocol. Frontier model emits ranking → postlex finalizes
7
- // → agent renders. The envelope makes the protocol legible to the agent
7
+ // → agent renders. The canonical six-key response envelope makes the protocol legible to the agent
8
8
  // without piling conditional logic into SKILL.md.
9
9
 
10
10
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
@@ -178,61 +178,92 @@ const determine_next_step = (final_ordering, query, clarification_turns_used) =>
178
178
 
179
179
  if (all_strong || final_ordering.length === 0) {
180
180
  return {
181
+ kind: 'continuation',
181
182
  action: 'present_to_user',
182
183
  instructions: 'Render `data.final_ordering` as a table to the user, with skill name, slug, description, and rationale. Include `data.formulated_query` in your preamble.',
183
- context: null,
184
+ context: { original_query: query, clarification_turns_used: turns_used },
184
185
  }
185
186
  }
186
187
 
187
188
  if (turns_remaining <= 0) {
188
189
  return {
190
+ kind: 'continuation',
189
191
  action: 'present_to_user',
190
192
  instructions: 'Even after reranking, no top result is a strong match — but the clarification budget (2 turns) is spent. Render `data.final_ordering` honestly: note that no result was a strong match, and present what you have so the user can decide. Do NOT ask another clarifying question.',
191
- context: null,
193
+ context: { original_query: query, clarification_turns_used: turns_used },
192
194
  }
193
195
  }
194
196
 
195
197
  return {
196
- action: 'clarify',
197
- instructions: `After reranking, no top result is a strong match. Ask the user one of \`suggested_questions\` using your agent's question mechanism (e.g. AskUserQuestion in Claude Code), then re-run search with the refined query and add --clarification-turns-used ${turns_used + 1}. The last option is always "Just search anyway" — honor it by re-running with the original query unchanged.`,
198
- suggested_questions: [
199
- {
200
- question: 'After reranking I still don\'t have a strong match. Could you narrow the scope a bit?',
201
- options: [
202
- { label: 'Focus on a specific stack (Node / Python / Go / etc.)', refined_query_hint: 'stack-specific' },
203
- { label: 'Focus on a specific platform (AWS / GCP / Vercel / etc.)', refined_query_hint: 'platform-specific' },
204
- { label: 'Broaden the search — show me more options', refined_query_hint: 'broader' },
205
- { label: 'Just search anyway', refined_query_hint: null },
206
- ],
207
- },
208
- ],
209
- max_turns_remaining: turns_remaining,
198
+ kind: 'clarification',
199
+ action: 'clarify_query',
200
+ instructions: `After reranking, no top result is a strong match. Ask the user one of \`context.suggested_questions\` using your agent's question mechanism (e.g. AskUserQuestion in Claude Code), then re-run search with the refined query and add --clarification-turns-used ${turns_used + 1}. The last option is always "Just search anyway" — honor it by re-running with the original query unchanged.`,
210
201
  context: {
211
202
  original_query: query,
212
203
  clarification_turns_used: turns_used,
204
+ max_turns_remaining: turns_remaining,
205
+ suggested_questions: [
206
+ {
207
+ question: 'After reranking I still don\'t have a strong match. Could you narrow the scope a bit?',
208
+ options: [
209
+ { label: 'Focus on a specific stack (Node / Python / Go / etc.)', refined_query_hint: 'stack-specific' },
210
+ { label: 'Focus on a specific platform (AWS / GCP / Vercel / etc.)', refined_query_hint: 'platform-specific' },
211
+ { label: 'Broaden the search — show me more options', refined_query_hint: 'broader' },
212
+ { label: 'Just search anyway', refined_query_hint: null },
213
+ ],
214
+ },
215
+ ],
213
216
  },
217
+ principal_authorization_required: true,
214
218
  }
215
219
  }
216
220
 
221
+ // ─── next_step consumers (pure — exported for unit testing) ─────────────────
222
+ // run() reacts to the next_step that determine_next_step emitted: it fires a
223
+ // `clarify_triggered` telemetry beacon and renders a clarification notice in
224
+ // text mode. Those two decisions are factored out here so they can be
225
+ // unit-tested without spawning the process or hitting the telemetry endpoint.
226
+
227
+ // True when the emitted next_step is the post-rerank clarification step.
228
+ // The closed action enum value is `clarify_query` (next_step_actions.js) —
229
+ // matching the bare label "clarify" here silently disables the clarification
230
+ // beacon and notice, since determine_next_step never emits "clarify".
231
+ const is_clarify_next_step = (next_step) =>
232
+ !!next_step && next_step.action === 'clarify_query'
233
+
234
+ // The first suggested clarifying question to surface in text mode, or null.
235
+ // suggested_questions lives under next_step.context (spec § 4.5), not at the
236
+ // bare next_step root.
237
+ const first_clarify_question = (next_step) => {
238
+ const list = next_step && next_step.context && next_step.context.suggested_questions
239
+ const q = Array.isArray(list) && list[0]
240
+ return q && typeof q.question === 'string' ? q.question : null
241
+ }
242
+
217
243
  // Envelope used when the LLM's ranking is malformed and we need to ask it
218
- // to re-emit. The CLI exits 0 the protocol continues via the envelope.
219
- const build_retry_envelope = (query, reason, clarification_turns_used, retry_count) => ({
220
- data: null,
221
- error: {
222
- code: 'ranking_schema_mismatch',
223
- message: reason,
224
- exit_code: 0,
225
- },
226
- next_step: {
227
- action: 'retry_rank',
228
- instructions: 'Your ranking failed validation. Re-emit a JSON object matching the `rerank_response_schema` from the original search response, then re-pipe to `happyskills postlex --query "<q>" --ranking -`. Max one retry — if it fails again, render the search results without rerank (the API\'s relevance_score order is the fallback).',
229
- context: {
230
- original_query: query,
231
- clarification_turns_used: clarification_turns_used | 0,
232
- retry_count: (retry_count | 0) + 1,
244
+ // to re-emit. Returns the canonical six-key response envelope (spec § 4) so postlex
245
+ // can be unit-tested against the closed schema.
246
+ const build_retry_envelope = (query, reason, clarification_turns_used, retry_count) => {
247
+ const { build_envelope } = require('../ui/envelope')
248
+ return build_envelope({
249
+ data: {},
250
+ error: {
251
+ code: 'RANKING_SCHEMA_MISMATCH',
252
+ message: reason,
233
253
  },
234
- },
235
- })
254
+ next_step: {
255
+ kind: 'recovery',
256
+ action: 'retry_rank',
257
+ instructions: 'Your ranking failed validation. Re-emit a JSON object matching the `rerank_response_schema` from the original search response, then re-pipe to `happyskills postlex --query "<q>" --ranking -`. Max one retry — if it fails again, render the search results without rerank (the API\'s relevance_score order is the fallback).',
258
+ context: {
259
+ original_query: query,
260
+ clarification_turns_used: clarification_turns_used | 0,
261
+ retry_count: (retry_count | 0) + 1,
262
+ },
263
+ },
264
+ meta_overrides: { command: 'postlex' },
265
+ })
266
+ }
236
267
 
237
268
  // ─── I/O helpers ───────────────────────────────────────────────────────────
238
269
 
@@ -405,7 +436,10 @@ const run = (args) => catch_errors('Postlex failed', async () => {
405
436
  process.stderr.write(`postlex: ${parse_error}\n`)
406
437
  const env = build_retry_envelope(query, parse_error, clarification_turns_used, 0)
407
438
  print_json(env)
408
- return process.exit(EXIT_CODES.SUCCESS)
439
+ // Spec § 9.2 — process exit code must mirror meta.exit_code. The
440
+ // retry envelope carries error.code=RANKING_SCHEMA_MISMATCH which
441
+ // maps to a non-zero exit; honor that rather than forcing 0.
442
+ return process.exit(env.meta.exit_code)
409
443
  }
410
444
 
411
445
  // Validate per-item
@@ -438,7 +472,7 @@ const run = (args) => catch_errors('Postlex failed', async () => {
438
472
  promoted_from_rank,
439
473
  exact_match_count_in_window,
440
474
  })
441
- if (next_step.action === 'clarify') {
475
+ if (is_clarify_next_step(next_step)) {
442
476
  fire_discovery_telemetry({
443
477
  event: 'clarify_triggered',
444
478
  intent_id,
@@ -475,9 +509,9 @@ const run = (args) => catch_errors('Postlex failed', async () => {
475
509
  console.log(format_human_row(row))
476
510
  if (i < final_ordering.length - 1) console.log('')
477
511
  })
478
- if (next_step.action === 'clarify') {
512
+ if (is_clarify_next_step(next_step)) {
479
513
  console.log(`\n ${yellow('No top result is a strong match. Suggested clarification:')}`)
480
- console.log(` ${dim(next_step.suggested_questions[0].question)}`)
514
+ console.log(` ${dim(first_clarify_question(next_step))}`)
481
515
  } else if (next_step.action === 'present_to_user' && !final_ordering.slice(0, 3).every(r => STRONG_TIERS.has(r.match_quality))) {
482
516
  console.log(`\n ${yellow('No top result is a strong match (clarification budget spent).')}`)
483
517
  }
@@ -491,6 +525,8 @@ module.exports = {
491
525
  apply_postlex,
492
526
  build_final_ordering,
493
527
  determine_next_step,
528
+ is_clarify_next_step,
529
+ first_clarify_question,
494
530
  build_retry_envelope,
495
531
  parse_input,
496
532
  resolve_row_name,
@@ -14,6 +14,8 @@ const {
14
14
  apply_postlex,
15
15
  build_final_ordering,
16
16
  determine_next_step,
17
+ is_clarify_next_step,
18
+ first_clarify_question,
17
19
  build_retry_envelope,
18
20
  parse_input,
19
21
  resolve_row_name,
@@ -172,25 +174,32 @@ describe('determine_next_step', () => {
172
174
  rationale: '',
173
175
  }))
174
176
 
175
- it('emits present_to_user when top 3 are all strong/good', () => {
177
+ // Per spec 260525-cli-default-json § 7.1 the closed action names are
178
+ // "clarify_query" (kind=clarification) and "present_to_user"
179
+ // (kind=continuation). Tests below use those — the older bare "clarify"
180
+ // label is deprecated.
181
+
182
+ it('emits present_to_user (kind=continuation) when top 3 are all strong/good', () => {
176
183
  const ns = determine_next_step(fo(['strong', 'good', 'strong']), 'q', 0)
177
184
  assert.equal(ns.action, 'present_to_user')
185
+ assert.equal(ns.kind, 'continuation')
178
186
  })
179
187
 
180
- it('emits clarify when top 3 include a partial AND budget remains', () => {
188
+ it('emits clarify_query (kind=clarification) when top 3 include a partial AND budget remains', () => {
181
189
  const ns = determine_next_step(fo(['strong', 'partial', 'good']), 'q', 0)
182
- assert.equal(ns.action, 'clarify')
183
- assert.equal(ns.max_turns_remaining, 2)
190
+ assert.equal(ns.action, 'clarify_query')
191
+ assert.equal(ns.kind, 'clarification')
192
+ // max_turns_remaining + suggested_questions live inside context (spec § 4.5).
193
+ assert.equal(ns.context.max_turns_remaining, 2)
184
194
  assert.equal(ns.context.clarification_turns_used, 0)
185
- // Always includes a "Just search anyway" option.
186
- const last = ns.suggested_questions[0].options[ns.suggested_questions[0].options.length - 1]
195
+ const last = ns.context.suggested_questions[0].options[ns.context.suggested_questions[0].options.length - 1]
187
196
  assert.match(last.label, /search anyway/i)
188
197
  })
189
198
 
190
- it('emits clarify when top 3 include a weak/null AND budget remains', () => {
199
+ it('emits clarify_query when top 3 include a weak/null AND budget remains', () => {
191
200
  const ns = determine_next_step(fo(['weak', 'good', null]), 'q', 1)
192
- assert.equal(ns.action, 'clarify')
193
- assert.equal(ns.max_turns_remaining, 1)
201
+ assert.equal(ns.action, 'clarify_query')
202
+ assert.equal(ns.context.max_turns_remaining, 1)
194
203
  })
195
204
 
196
205
  it('emits present_to_user with budget-spent note when budget exhausted', () => {
@@ -201,7 +210,7 @@ describe('determine_next_step', () => {
201
210
 
202
211
  it('clamps clarification_turns_used to [0, 2]', () => {
203
212
  const ns_neg = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', -1)
204
- assert.equal(ns_neg.max_turns_remaining, 2)
213
+ assert.equal(ns_neg.context.max_turns_remaining, 2)
205
214
  const ns_big = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', 99)
206
215
  assert.equal(ns_big.action, 'present_to_user')
207
216
  })
@@ -217,17 +226,75 @@ describe('determine_next_step', () => {
217
226
  })
218
227
  })
219
228
 
229
+ // ─── next_step consumer wiring (clarify_query drift regression) ────────────
230
+ // determine_next_step (the producer) emits next_step.action === "clarify_query"
231
+ // with suggested_questions nested under next_step.context (spec § 4.5). run()'s
232
+ // telemetry + text-render consumers must recognize that exact contract. The
233
+ // historical bug: the consumers checked for the bare label "clarify" and read
234
+ // next_step.suggested_questions at the wrong depth, so the clarify_triggered
235
+ // beacon never fired and the clarification notice never rendered — a SILENT
236
+ // failure (no error) at the one moment the harness is meant to engage.
237
+ describe('postlex clarify_query consumer wiring', () => {
238
+ const weak_ordering = [
239
+ { rank: 1, candidate_id: 1, match_quality: 'weak' },
240
+ { rank: 2, candidate_id: 2, match_quality: 'partial' },
241
+ { rank: 3, candidate_id: 3, match_quality: 'weak' },
242
+ ]
243
+
244
+ it('recognizes the producer\'s clarify_query next_step (telemetry + render gate)', () => {
245
+ const ns = determine_next_step(weak_ordering, 'deploy aws', 0)
246
+ assert.equal(ns.action, 'clarify_query', 'producer emits the canonical action')
247
+ assert.equal(
248
+ is_clarify_next_step(ns), true,
249
+ 'consumer must recognize action "clarify_query" so the clarify_triggered beacon fires and the notice renders',
250
+ )
251
+ })
252
+
253
+ it('reads the suggested question from next_step.context.suggested_questions', () => {
254
+ const ns = determine_next_step(weak_ordering, 'deploy aws', 0)
255
+ const q = first_clarify_question(ns)
256
+ assert.equal(typeof q, 'string', 'suggested question must be resolved from next_step.context')
257
+ assert.ok(q.length > 0, 'must surface the first suggested clarifying question for text-mode rendering')
258
+ })
259
+
260
+ it('does not treat the success path (present_to_user) as a clarify step', () => {
261
+ const ns = determine_next_step(
262
+ [
263
+ { rank: 1, candidate_id: 1, match_quality: 'strong' },
264
+ { rank: 2, candidate_id: 2, match_quality: 'good' },
265
+ { rank: 3, candidate_id: 3, match_quality: 'strong' },
266
+ ],
267
+ 'q', 0,
268
+ )
269
+ assert.equal(ns.action, 'present_to_user')
270
+ assert.equal(is_clarify_next_step(ns), false)
271
+ assert.equal(first_clarify_question(ns), null)
272
+ })
273
+ })
274
+
220
275
  // ─── build_retry_envelope ─────────────────────────────────────────────────
276
+ // Envelope contract: six-key { ok, data, error, next_step, warnings, meta }
277
+ // with data={}, error.code=RANKING_SCHEMA_MISMATCH (closed enum), kind=recovery,
278
+ // action=retry_rank, exit_code mirrored on meta (NOT on error).
279
+
280
+ const { validate_envelope } = require('../schema/envelope_validator')
221
281
 
222
282
  describe('build_retry_envelope', () => {
223
- it('produces a retry_rank envelope with all required fields', () => {
283
+ it('produces a retry_rank envelope conforming to the closed schema', () => {
224
284
  const env = build_retry_envelope('the query', 'ranking missing field rationale', 1, 0)
225
- assert.equal(env.data, null)
226
- assert.equal(env.error.code, 'ranking_schema_mismatch')
227
- assert.equal(env.error.exit_code, 0)
285
+ const { ok, errors } = validate_envelope(env)
286
+ assert.equal(ok, true, `envelope failed validation: ${JSON.stringify(errors)}`)
287
+ assert.equal(env.ok, false, 'retry envelope is a failure (ok=false)')
288
+ assert.deepEqual(env.data, {}, 'data must be the empty object, never null')
289
+ assert.equal(env.error.code, 'RANKING_SCHEMA_MISMATCH')
290
+ assert.equal(typeof env.error.message, 'string')
291
+ assert.equal('exit_code' in env.error, false, 'exit_code lives on meta, not error')
292
+ assert.equal(env.next_step.kind, 'recovery')
228
293
  assert.equal(env.next_step.action, 'retry_rank')
229
294
  assert.equal(env.next_step.context.clarification_turns_used, 1)
230
295
  assert.equal(env.next_step.context.retry_count, 1)
296
+ assert.equal(env.meta.command, 'postlex')
297
+ assert.ok(env.meta.exit_code > 0, 'retry envelope must mirror a non-zero exit code')
231
298
  })
232
299
 
233
300
  it('increments retry_count on each call', () => {
@@ -344,8 +411,16 @@ describe('normalize_data_rows', () => {
344
411
  // ─── extract_data_array_from_search_output (v0.48.0) ──────────────────────
345
412
 
346
413
  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 }
414
+ it('extracts data.results from the canonical (spec 260525) envelope shape', () => {
415
+ // Canonical envelope: every key always an object, never null.
416
+ const env = {
417
+ ok: true,
418
+ data: { query: 'q', mode: 'semantic', results: [{ name: 'foo' }] },
419
+ error: {},
420
+ next_step: {},
421
+ warnings: [],
422
+ meta: { command: 'search', exit_code: 0, envelope_schema_version: '1.0.0' },
423
+ }
349
424
  const r = extract_data_array_from_search_output(env)
350
425
  assert.equal(r.length, 1)
351
426
  assert.equal(r[0].name, 'foo')
@@ -380,9 +455,12 @@ describe('parse_input with --search-output', () => {
380
455
  it('extracts data.results from a full search envelope passed as the third argument', () => {
381
456
  const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'top' }] })
382
457
  const search_out = JSON.stringify({
458
+ ok: true,
383
459
  data: { query: 'q', mode: 'semantic', results: [{ name: 'deploy-aws', workspace_slug: 'acme' }] },
384
- error: null,
385
- next_step: null,
460
+ error: {},
461
+ next_step: {},
462
+ warnings: [],
463
+ meta: { command: 'search', exit_code: 0, envelope_schema_version: '1.0.0' },
386
464
  })
387
465
  const r = parse_input(ranking, null, search_out)
388
466
  assert.equal(r.parse_error, null)
@@ -125,7 +125,11 @@ const run = (args) => catch_errors('Pull failed', async () => {
125
125
  next_step: result.next_step || null,
126
126
  error: result.error || null
127
127
  })
128
- if (result.next_step) process.exit(EXIT_CODES.ERROR)
128
+ // Spec § 9.1 exit code reflects whether the OPERATION succeeded,
129
+ // not whether a next_step is present. Success-with-followup (data
130
+ // populated, next_step set, error empty) is exit 0. A populated
131
+ // error is the signal for non-zero exit.
132
+ if (result.error && result.error.code) process.exit(EXIT_CODES.ERROR)
129
133
  return
130
134
  }
131
135
  if (result.error) {
@@ -58,6 +58,52 @@ const resolve_lock_entry = (raw, project_root, is_global) => catch_errors('Faile
58
58
  return { full, lock_entry, lock_data }
59
59
  })
60
60
 
61
+ // Expand the internal { action, context: {...} } reconcile result into the
62
+ // closed-enum six-key next_step shape (spec § 4.5 / § 7). Returns {} for
63
+ // the no-followup case.
64
+ const NEXT_STEP_INSTRUCTIONS = {
65
+ resolve_regression: 'Disk version is older than lock. Pick one of the listed options and re-run with --apply <option>.',
66
+ resolve_missing_skill_json: 'skill.json is missing. Pick one of the listed restore options and re-run with --apply <option>.',
67
+ resolve_missing_dir: 'The skill directory is missing. Pick one of the listed options and re-run with --apply <option>.',
68
+ install_first: 'Skill is not in the lock file. Install it first, then re-run reconcile.',
69
+ }
70
+ const NEXT_STEP_KINDS_MAP = {
71
+ resolve_regression: 'decision',
72
+ resolve_missing_skill_json: 'decision',
73
+ resolve_missing_dir: 'decision',
74
+ install_first: 'routing',
75
+ }
76
+ const enrich_reconcile_next_step = (ns, full) => {
77
+ if (!ns || typeof ns !== 'object') return {}
78
+ const action = ns.action
79
+ if (!action) return {}
80
+ const kind = NEXT_STEP_KINDS_MAP[action] || 'decision'
81
+ if (action === 'install_first') {
82
+ return {
83
+ kind,
84
+ action,
85
+ instructions: NEXT_STEP_INSTRUCTIONS.install_first,
86
+ context: { commands: [`npx happyskills install ${full} --json`] },
87
+ route_to_skill: 'happyskills',
88
+ }
89
+ }
90
+ const opts = ns.context?.options || []
91
+ return {
92
+ kind,
93
+ action,
94
+ instructions: NEXT_STEP_INSTRUCTIONS[action] || 'Pick one of the listed options and re-run.',
95
+ context: {
96
+ options: opts,
97
+ ...(ns.context?.drift ? { drift: ns.context.drift } : {}),
98
+ commands: opts.length > 0
99
+ ? [`npx happyskills reconcile ${full} --apply ${opts[0]} --json`]
100
+ : undefined,
101
+ },
102
+ principal_authorization_required: true,
103
+ route_to_skill: 'happyskills-sync',
104
+ }
105
+ }
106
+
61
107
  const apply_restore_from_lock_version = ({ skill_dir, lock_entry }) => catch_errors('Failed to restore from lock', async () => {
62
108
  const [read_err, manifest] = await read_manifest(skill_dir)
63
109
  if (read_err) throw e('Failed to read skill.json', read_err)
@@ -197,10 +243,12 @@ const run = (args) => catch_errors('Reconcile failed', async () => {
197
243
  if (recon_err) throw e('Reconcile failed', recon_err)
198
244
 
199
245
  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)
246
+ const { emit_envelope } = require('../ui/envelope')
247
+ // Inline the human-readable hint into data so the envelope's
248
+ // next_step slot remains reserved for actionable protocol followups.
249
+ const data = result.hint !== undefined ? { ...result.data, hint: result.hint } : result.data
250
+ const next_step = enrich_reconcile_next_step(result.next_step, full)
251
+ emit_envelope({ data, next_step })
204
252
  return
205
253
  }
206
254
 
@@ -217,11 +217,14 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
217
217
  drift: target_info.drift
218
218
  }),
219
219
  next_step: {
220
- action: 'reconcile_first',
220
+ kind: 'recovery',
221
+ action: 'reconcile_first',
222
+ instructions: 'Drift must be resolved before release. Run reconcile, follow its next_step, then retry release.',
221
223
  context: {
222
- reconcile_command: `npx happyskills reconcile ${lock_key} --json`,
223
- skill: lock_key
224
- }
224
+ commands: [`npx happyskills reconcile ${lock_key} --json`],
225
+ skill: lock_key,
226
+ },
227
+ route_to_skill: 'happyskills-sync',
225
228
  }
226
229
  })
227
230
  return { code: EXIT_CODES.ERROR, envelope: restored }
@@ -229,14 +232,26 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
229
232
  if (target_info.status === 'missing_version') {
230
233
  const restored = await restore_and({
231
234
  ...envelope_error('MISSING_VERSION', '--no-bump was passed but disk is not ahead of lock; nothing to publish.'),
232
- next_step: { action: 'specify_bump_type', context: { current_version: manifest.version } }
235
+ next_step: {
236
+ kind: 'decision',
237
+ action: 'specify_bump_type',
238
+ instructions: 'No target version determined. Pick a bump type and re-run.',
239
+ context: { options: ['patch', 'minor', 'major'], current_version: manifest.version },
240
+ principal_authorization_required: true,
241
+ }
233
242
  })
234
243
  return { code: EXIT_CODES.USAGE, envelope: restored }
235
244
  }
236
245
  if (target_info.status === 'invalid_bump') {
237
246
  const restored = await restore_and({
238
247
  ...envelope_error('INVALID_BUMP', `--bump "${target_info.bump}" is not patch/minor/major or a valid semver.`),
239
- next_step: { action: 'specify_bump_type', context: { current_version: manifest.version } }
248
+ next_step: {
249
+ kind: 'decision',
250
+ action: 'specify_bump_type',
251
+ instructions: 'The bump value is not valid. Pick a bump type and re-run.',
252
+ context: { options: ['patch', 'minor', 'major'], current_version: manifest.version, got: target_info.bump },
253
+ principal_authorization_required: true,
254
+ }
240
255
  })
241
256
  return { code: EXIT_CODES.USAGE, envelope: restored }
242
257
  }
@@ -244,8 +259,11 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
244
259
  const restored = await restore_and({
245
260
  ...envelope_error('MISSING_VERSION', 'No --bump provided and no CHANGELOG entry indicates an intended next version.'),
246
261
  next_step: {
247
- action: 'specify_bump_type',
248
- context: { current_version: manifest.version, options: ['patch', 'minor', 'major', 'explicit-version'] }
262
+ kind: 'decision',
263
+ action: 'specify_bump_type',
264
+ instructions: 'No target version provided. Pick a bump type and re-run.',
265
+ context: { options: ['patch', 'minor', 'major'], current_version: manifest.version },
266
+ principal_authorization_required: true,
249
267
  }
250
268
  })
251
269
  return { code: EXIT_CODES.USAGE, envelope: restored }
@@ -254,12 +272,15 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
254
272
  const restored = await restore_and({
255
273
  ...envelope_error('BUMP_DISAGREEMENT', `--bump ${target_info.requested_bump} disagrees with the disk version ${target_info.disk_version}.`),
256
274
  next_step: {
257
- action: 'resolve_bump_disagreement',
275
+ kind: 'decision',
276
+ action: 'resolve_bump_disagreement',
277
+ instructions: 'The --bump value disagrees with the disk version. Pick one and re-run.',
258
278
  context: {
259
- disk_version: target_info.disk_version,
279
+ disk_version: target_info.disk_version,
260
280
  requested_bump: target_info.requested_bump,
261
- lock_version: target_info.lock_version
262
- }
281
+ lock_version: target_info.lock_version,
282
+ },
283
+ principal_authorization_required: true,
263
284
  }
264
285
  })
265
286
  return { code: EXIT_CODES.USAGE, envelope: restored }
@@ -299,7 +320,13 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
299
320
  if (cf_err || !cf_content) {
300
321
  const restored = await restore_and({
301
322
  ...envelope_error('CHANGELOG_SOURCE_UNREADABLE', `Could not read --changelog-from "${changelog_from}".`),
302
- next_step: { action: 'provide_changelog' }
323
+ next_step: {
324
+ kind: 'recovery',
325
+ action: 'provide_changelog',
326
+ instructions: 'The --changelog-from file could not be read. Provide a readable file and re-run.',
327
+ context: {},
328
+ principal_authorization_required: true,
329
+ }
303
330
  })
304
331
  return { code: EXIT_CODES.USAGE, envelope: restored }
305
332
  }
@@ -311,8 +338,11 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
311
338
  const restored = await restore_and({
312
339
  ...envelope_error('MISSING_CHANGELOG_ENTRY', `CHANGELOG.md does not contain a ## [${target_version}] entry.`),
313
340
  next_step: {
314
- action: 'provide_changelog',
315
- context: { target_version, current_top_entry: cl_top }
341
+ kind: 'recovery',
342
+ action: 'provide_changelog',
343
+ instructions: 'CHANGELOG.md is missing an entry for the target version. Add the entry and re-run release.',
344
+ context: { target_version, current_top_entry: cl_top },
345
+ principal_authorization_required: true,
316
346
  }
317
347
  })
318
348
  return { code: EXIT_CODES.USAGE, envelope: restored }